Part 3 of 7 · Supplier bill matcher series ~5 min read

How a bill gets matched three ways

A bill has been read into clean lines. Now the matcher Lambda has to decide whether it’s right. It pulls the purchase order the bill refers to and the goods-received note for that order, walks the bill one line at a time, and checks three things on each: right item, right quantity received, right price — each within the tolerance from the rules doc. The whole decision is plain Python. No model. No vector lookup. Every tolerance lives in the rules doc, where a buyer can edit it without a deploy.

Key takeaways

  • The match is three-way: the bill against the purchase order and against the goods-received note.
  • Each line is checked for item, quantity received, and unit price — each against a tolerance.
  • Tolerances live in the rules doc — e.g. price within 2% or $5; quantity must match the dock.
  • Four outcomes per bill: matched, price variance, quantity variance, no PO.
  • The matcher never calls a model. The decision is entirely deterministic.

The decision flow, per bill

Decision flow per bill on the three-way match A vertical decision flow diagram. At the top, an input box "Bill, read into lines" with the supplier, bill number, PO reference, and each line's item, quantity, and unit price. Below that, a check "Find the purchase order" — match the bill to an open PO by PO number, or by supplier and item if the number is missing. The next step "Pull the goods-received note" — fetch the dock's record of what actually arrived for that PO. The next step "Check each line: item, qty, price" — for every bill line, confirm the item is on the PO, the billed quantity matches the goods received, and the unit price matches the PO price, each within the tolerance from the rules doc. The next check "All lines clean?" — if every line is clean, route to "Matched"; if a quantity doesn't match the dock, route to "Quantity variance". The next check "No PO, or price too high?" — if there was no purchase order to match against, route to "No PO"; otherwise a unit price was over tolerance, so route to "Price variance". Each terminal box — Matched, No PO, Price variance, Quantity variance — writes the outcome and the exact failing line to the bm-results table and emits an event for the approval desk. A note at the bottom: the rules doc holds every tolerance; the matcher's code only enforces them — change a tolerance in the doc and the next bill uses the new value. Bill, read into lines supplier · bill no · PO ref · lines Step 1 Find the purchase order by PO number, else supplier Step 2 Pull goods-received note what the dock received Step 3 Check each line item · quantity · unit price Step 4 All lines clean? yes → matched qty off the dock → qty Step 5 No PO, or price too high? no order → no PO; else price Matched clear to approve No PO no order to match Price variance unit price too high Qty variance dock got fewer all clean clean qty off no PO price The rules doc holds every tolerance — change one and the next bill uses the new value.
Fig 3. The matcher’s decision tree, per bill. Five steps decide which of four outcomes applies. The rules doc holds every tolerance; the matcher only enforces them.

Three-way, not two-way: why the dock matters

A two-way match compares the bill to the purchase order: did they bill what we ordered, at the price we agreed? That catches a wrong price. It does not catch a wrong delivery. If you ordered 100 boxes and the supplier billed 100 but only 84 turned up at the dock, a two-way match pays for 100. The 16 missing boxes are your money, gone.

The third leg is the goods-received note — the dock’s record of what actually arrived. The matcher checks the bill against both the order and the receipt. Quantity is judged against what the dock received, not against what was ordered, because the thing you should pay for is the thing you actually got. Price is judged against the PO, because that’s the number you agreed. Item is checked against both. Three documents, one decision.

Tolerances: a little drift is fine, a lot isn’t

Real bills don’t match to the penny. A supplier rounds, a freight surcharge nudges a line, an exchange rate moves. If the matcher flagged every one-cent difference, every bill would be a variance and the whole thing would be noise. So the rules doc sets a tolerance per check, in plain prose: “Price: allow the larger of 2% or $5 per line before flagging. Quantity: must match the goods-received note exactly, except where the PO is marked part-delivery, in which case allow the agreed installment. Item: must be the exact item code on the PO.”

Tolerances are per-supplier-overridable. A supplier you trust and whose prices float with a published index can get a looser price tolerance; a new supplier can get a tighter one. The override is a column in the rules doc, so a buyer changes it without anyone touching code.

Four outcomes, always

Every bill, every time, lands in exactly one of four outcomes. The names are simple on purpose.

  • Matched. Every line agrees with the PO and the goods-received note inside tolerance. The bill is cleared for a manager’s one-tap approval. Most bills, most days, are matched.
  • Price variance. The right items arrived in the right quantity, but a unit price on the bill is higher than the PO agreed, beyond tolerance. The matcher records the exact line, the billed price, the PO price, and the dollar gap across the line.
  • Quantity variance. A billed quantity doesn’t match what the dock received. Usually that means the dock got fewer than billed (you’re being charged for goods that didn’t fully arrive), but it can also mean a duplicate or a split delivery the bill didn’t account for. The matcher records the billed quantity and the received quantity side by side.
  • No PO. The bill has no purchase order to match against — no PO number on it, and no open order that fits the supplier and items. This is the riskiest outcome, because there’s nothing to check the bill against. It always goes to a human; it never clears automatically.

State that makes the decision deterministic

The matcher reads the clean bill lines from the bm-bills table and the mirrored PO and goods-received data from S3, and writes the outcome to bm-results: (bill_id, outcome, failing_lines, po_number, checked_at). With the bill, the PO, the receipt, and the tolerances all fixed, the outcome is fully determined. Re-running the match on the same inputs produces the same outcome — there’s no randomness, no model, nothing that drifts. That matters when a finance manager asks “why was this flagged?” six weeks later: the answer is a line of arithmetic they can read, not a model’s opinion.

Why the match uses no model

The matcher could ask a model “does this bill look right?” It doesn’t. Two reasons. First, deciding whether to pay a supplier is exactly the kind of decision that must be predictable and explainable — if the PO says $3.80 and the bill says $4.20, that’s a variance, full stop, and no amount of model judgment should soften it. Second, the match runs on every bill, and a model on that hot path would add cost and variance to the one part of the system that should have neither.

The model already did its job upstream, in Part 2, turning a messy PDF into clean lines. From there, comparing numbers against tolerances is plain Python and should stay that way. Next post: how a flagged bill reaches the right person, with the exact line and the exact gap, through four small guardrails.

All posts