Skip to content

Three-Way Match: pay an invoice only if the order and the receipt agree

The data in this project is synthetic — generated locally by data/make_data.py, with no external source. That is deliberate: you can only score a three-way match if you know which invoices are truly exceptions, and labelled procurement data is exactly what companies never share. No signup, no downloads, no GPU.

Before you start

New to accounts payable? Start here.

Before a company pays a supplier’s bill, a careful accounts-payable team checks it against two other documents:

  • the purchase order (PO) — what we agreed to buy, and at what price;
  • the goods receipt — what the warehouse says actually showed up.

Only if all three agree does the invoice get paid. This is a three-way match, and it exists because of one specific leak: a supplier ships less than you ordered, then bills you for the full order anyway. If you only compare the invoice to the PO — a two-way match — the bill looks perfect: the quantity billed equals the quantity ordered. The overcharge is invisible until you bring in the third document and ask what actually arrived.

A tiny example of the exception a two-way match misses:

PO ordered 100 units @ $5.00
receipt only 60 units arrived <- short shipment
invoice bills 100 units @ $5.00 <- looks fine vs the PO; wrong vs the receipt

Two-way match: invoice qty (100) == PO qty (100) → pass. Three-way match: invoice qty (100) > received qty (60) → exception, block the payment. That gap is the whole point.

What you’ll learn: build a two-way match, watch it wave through short-shipment overbilling, then add the goods receipt to close the gap — and bucket the exceptions by reason (no PO, price variance, over-billed) with the dollars blocked. Section 3 lists exactly what you’ll be able to do.

New to Python too? See Start Here — it starts from zero, and shows how to run this in your browser with nothing to install.

1. The Brief

You’re handed three procurement files — purchase orders, goods receipts, and invoices — and asked the question that protects the cash: which invoices should we refuse to pay, and why? You’ll build a matcher that flags three kinds of exception and reports the money each blocks.

A three-way match is a standard control in every accounts-payable function; entire ERP modules exist to run it. The reason it matters is that the most expensive error — being billed for goods that never arrived — is exactly the one a naive invoice-vs-PO check cannot see. Getting the third document into the comparison is the whole job, and it’s the same skill whether the documents are POs and receipts or any other pair of records that must reconcile before an action is allowed.

2. Difficulty & time

Difficulty 3 / 10. One tool (pandas), one concept (reconcile three documents before acting), and a fully specified path — no algorithm, no modelling. Calibrated against the ledger (G10): a peer of silent-row-loss, idempotent-loads, and duplicate-invoices (all 3) — single-mechanic reconciliation builds where the lesson is a specific data-handling judgment (here: compare to what arrived, not what was ordered, and aggregate split shipments first), not a pipeline or a model. It sits below maverick-spend (4), which layers a conditional policy rule and a second detection type into one screen. It stays above the 1–2 band because the naive two-way match genuinely fails — it misses every short-shipment over-bill — and the fix (a group-by-and-aggregate join on a third document) is a real technique, not a one-liner.

Time is separate from difficulty: Part A is about an hour; Part B is 2–3 hours of your own work. The scripts run in under a second — the time is comprehension.

3. What you’ll be able to do after

  • Reconcile records across three sources — join invoices to purchase orders and goods receipts on a shared key and compare the fields that must agree.
  • Explain why a two-way match is not enough — and name the exact exception (short shipment billed in full) that only the third document catches.
  • Aggregate a one-to-many relationship before comparing — sum split-shipment receipts per PO with groupby(...).sum() so multi-shipment deliveries aren’t mis-flagged.
  • Classify and dollarize — bucket exceptions by reason (no PO, price variance, over-billed) and total the money each blocks.

The finished result

By the end of Part A you’ll have a matcher that catches what a two-way check can’t, with the money each exception blocks:

two-way match (invoice vs PO) ...... 57 exceptions
three-way match (+ goods receipt) .. 81 exceptions (+24 billed for goods that never arrived)
by reason: no_po 36 (~$824K) over_billed 24 (~$414K) price_variance 21 (~$699K)

The +24 are short shipments billed in full — invisible to an invoice-vs-PO check, and $414K you shouldn’t pay. Comparing to what arrived is the whole point.

4. Prereqs & time box

You can write a function and use a pandas DataFrame (groupby, read_csv, boolean logic, dict lookups). No procurement background needed — the primer above covers it.

Where to write and run code

Two ways to run this project — pick either:

  • In your browser (no install). If the project page shows a ▶ Run button, click it to run Part A right here — nothing to set up. Part A uses only pandas, which runs in the browser.
  • On your computer. The one-time setup — installing Python, downloading the code from the course site (no GitHub, no git), opening a terminal, creating a virtual environment, installing the requirements, and running Part A step by step — is covered once in Start Here. Come back here once python -c "import pandas" runs without error. Part B (writing your own solution) needs this local setup.

Time box

  • Part A — Guided Build: 60–90 minutes. Hard cap.
  • Part B — Your Turn: 2–4 hours. Hard cap.

5. Setup & data

The data is synthetic and generated locally — see the banner above and SOURCES.md for full provenance. One command creates both parts’ files:

Terminal window
python data/make_data.py

That writes six CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical because the seed is fixed):

  • Part Apurchase_orders_a.csv, goods_receipts_a.csv, invoices_a.csv (300 rows each). Every PO has exactly one goods receipt.
  • Part Bpurchase_orders_b.csv (900), goods_receipts_b.csv (1,249), invoices_b.csv (900). Receipts are often split across two shipments, so a PO can have more than one receipt row.

The columns: POs carry po_id, sku, qty_ordered, unit_price; receipts carry receipt_id, po_id, sku, qty_received; invoices carry invoice_id, po_id, sku, qty_billed, unit_price_billed. Whether an invoice is an exception is not in the files — the grader recovers it from the seed.

6. Part A — Guided Build

You’ll match the 300-invoice Part A set. Run the whole thing, or (better) one # %% cell at a time — see Start Here:

Terminal window
python part_a/match_invoices.py

Prefer the browser? If this page has a ▶ Run button, click it instead of the command above — Part A runs in your browser with nothing to install. The STEP output and every checkpoint below are identical either way.

▶ Try it now — run the real Part A right here in your browser. This one button makes the data and runs Part A for you — you do not need to install anything, and you do not need to type any of the python … commands anywhere on this page (those are only for running on your own computer instead). First run loads Python + this project’s libraries: usually ~10–20 s, a little longer for machine-learning projects.

The full, commented script is in part_a/match_invoices.py. The spine:

Step 1 — The three documents. Load POs, receipts, invoices; total the billed dollars.

Checkpoint: 300 of each; ~$7.0M billed. Build a po_id → (qty_ordered, unit_price) lookup and a po_id → total qty_received lookup (the latter already sums receipts per PO).

Step 2 — Two-way match (invoice vs PO only). Flag no-PO, price-variance, and billed-more-than-ordered invoices.

Checkpoint: 57 exceptions. But this only knows what was ordered — an invoice that bills the full order after a short shipment looks fine to it.

Step 3 — Three-way match (+ goods receipt). Compare qty_billed to qty_received, not to qty_ordered.

Checkpoint: 81 exceptions — +24 the two-way match missed. Those 24 bill for goods that never arrived. Comparing to the receipt, not the order, is the entire point of the exercise.

Step 4 — Bucket by reason, and total the money blocked.

Checkpoint: no_po 36 ($824K), over_billed 24 ($414K), price_variance 21 (~$699K). Writes part_a/exceptions_a.csv.

Why this and not that

  • Why the goods receipt at all. The two-way match can only ask “does the bill match the order?” The expensive fraud/error — billing for undelivered goods — passes that test by construction. Only “does the bill match what arrived?” catches it, and that needs the receipt.
  • Why aggregate qty_received per PO. A delivery can arrive in more than one shipment, so a single PO may have several receipt rows. Summing them per PO gives the true received quantity; comparing against one row would flag a legitimate multi-shipment delivery as short. In Part A every PO has one receipt, so the sum is a no-op — but you write the code now so it’s ready for Part B, where it isn’t.
  • Why report dollars, not just a count. “24 more exceptions” doesn’t authorize withholding a payment; “$414K billed for goods we never received” does. The dollar figure is the deliverable.

Functions you’ll meet (and where to read more)

FunctionWhat it does hereDocs
pd.read_csv(path)Load each of the three documentsread_csv
df.groupby("po_id")[c].sum()Aggregate split-shipment receipts per POgroupby
s.to_dict()Turn the per-PO totals into a fast lookupto_dict
df.itertuples()Walk invoices row by row to classifyitertuples
dict .get(k, 0)Look up received qty, defaulting to 0 when nothing arriveddict.get

If something looks off

  • “Why sum the receipts per PO when there’s only one receipt each?” In Part A every PO has exactly one goods receipt, so the groupby(...).sum() looks pointless — and it is, here. You write it now because Part B’s receipts arrive split across several rows, and the same code has to work there. It’s a deliberate no-op in Part A.
  • A checkpoint number doesn’t match. The data is seeded, so the counts and dollars are exact. Re-run python data/make_data.py (you may have skipped or edited it), then re-run Part A.
  • Setup problems (Python not found, pip, FileNotFoundError, re-activating the venv): see the troubleshooting list in Start Here.

7. Part B — Your Turn

Part B is the optional “your turn” half — a guided fill-in-the-blank, not a blank page. You get a working starter (part_b/starter.py) that already handles the easy version; your job is to finish the # TODO it marks out. More open than Part A’s step-by-step, but you are never staring at an empty file. If you finished Part A and want to prove the skill, this is where it happens. New to Python? It’s completely fine to stop after Part A and come back later.

Same core skill, bigger files, and receipts that arrive in pieces. Match the 900 invoices in invoices_b.csv against purchase_orders_b.csv and goods_receipts_b.csv, and flag every exception invoice. The starter (part_b/starter.py) does a two-way match and scores ~0.81 — below the floor.

The transfer: in Part B a PO’s goods can be split across several receipt rows. If you treat each receipt row as the whole delivery, a legitimate two-shipment order looks short-shipped and you over-flag. You must sum qty_received per po_id before comparing billed to received. The single-receipt assumption that worked in Part A breaks.

Acceptance criteria (checkable)

Your solution writes into part_b/out/:

  • exceptions.csv — one column invoice_id, every invoice you flag as an exception.
  • report.json — with keys invoices, flagged, achieved_metric (you can leave achieved_metric null; the grader scores you).

Then:

Terminal window
python part_b/starter.py
pytest part_b/test_solution.py -q

If pytest reports everything as skipped, it just means the output files don’t exist yet — run python part_b/starter.py first (once your solution fills in the TODOs), then re-run.

You’re done when the tests pass: your flags reach F1 ≥ 0.90 against the true exception set (the reference reaches 1.00). A two-way match scores ~0.81 and fails — aggregating the receipts and comparing to received is what clears the floor.

Constraints — what must be true of your solution, not how to get there

  • Compare qty_billed to the quantity received, not the quantity ordered.
  • Aggregate split-shipment receipts per PO before comparing (a PO can have several receipt rows).
  • Still catch the other two exception types: no matching PO, and unit price above the PO price beyond the 2% tolerance.
  • Don’t over-flag: a legitimate multi-shipment delivery that adds up to the ordered quantity is not an exception.

Hints

Hint 1 — reuse, then extend

Start from Part A’s three-way logic. The only thing that changes is that goods_receipts_b.csv has more than one row per PO. Run Part A’s approach unchanged and note the score plateaus below the floor — the mis-flagged rows are the split-shipment deliveries.

Hint 2 — aggregate before you compare

rec.groupby("po_id")["qty_received"].sum().to_dict() collapses the split shipments into one received total per PO. Compare qty_billed against that, not against a single receipt row.

Hint 3 — the three checks, in order (still not the code)

For each invoice: no matching PO → exception; else price billed above PO price × 1.02 → exception; else qty billed above the aggregated received qty → exception. Anything else is OK.

8. Self-check

You don’t need the answer key. You’re done when all of these hold:

  • pytest reports F1 ≥ 0.90 (aim for ~1.00 — the checks are exact once you compare to received).
  • Your flagged count is clearly above the two-way baseline (you added the receipt) and lands near the true ~276, not the whole file (you didn’t over-flag the split shipments).
  • Re-running from python data/make_data.py reproduces the same numbers — the data is seeded.

And the tell-tale: if flagging against qty_received didn’t raise your catch over the two-way match, you haven’t actually consulted the receipts — the short-shipment over-bills are invisible to an invoice-vs-PO check.

9. Stretch

  • Rank the blocks. Report the dollars blocked by reason and by supplier, so the biggest withholdings are top of the list.
  • Under-billing and partial credits. Right now only over-billing is an exception. Add a tolerance band and surface invoices billed for less than received — a different reconciliation question with the same machinery.
  • (Genuinely hard) Line-level matching. Split each PO into multiple line items (several SKUs) and match at the line level, where one invoice line must reconcile to the receipt line for the same SKU — the shape real ERP three-way matching actually runs on.

10. Ship it

Put this in a portfolio repo and it counts. In that repo’s README:

  • State the finding in one sentence a controller gets: “$414K was billed for goods we never received; here’s the three-way match that caught it and the invoices to hold.”
  • Show the catch climbing from the two-way match to the three-way match, and the +24 exceptions the receipt exposed, bucketed by reason with the dollars each blocks.
  • Include the aggregate-receipts-then-compare function and one paragraph on why comparing to the order instead of the receipt is the mistake that lets overbilling through. That paragraph is the part that signals you’ve actually done a three-way match.

Keep make_data.py and the tests in the repo so anyone can reproduce your numbers from zero.

11. Sources

Rendered from SOURCES.md.

FieldValue
DatasetSynthetic procurement three-way-match set: purchase orders, goods receipts, invoices (Part A 300/300/300, Part B 900/1,249/900)
TypeSynthetic
Generated bydata/make_data.py (this repo), seeds 20260718 (A) and 20260719 (B)
Source URLNot applicable — generated, not fetched
LicenseNot applicable — synthetic, no third-party rights
Access / generation date2026-07-18
RedistributionPermitted — synthetic; output regenerated from the seeded script, never committed
robots.txt checkedNot applicable — no scraping
ToS URLNot applicable — no external source

Synthetic data teaches the mechanic; it does not prove a finding about the real world. The exception patterns were injected on purpose — recovering them demonstrates the technique, not a claim about any real company’s payments.

Next up

Finished this one? Continue the Data Engineering track:

Schema Evolution: load a feed that changes its columns out from under you  ·  Browse all courses