threeway
Reconcile a purchase order against goods received against the supplier invoice, and find out which lines disagree and why.
Three systems will give you three different numbers for the same delivery. This tells you which line is wrong and names the reason, in one command.
No dependencies. Standard library only — no pandas, no click. Decimal
throughout, so money never drifts.
$ threeway --po po.csv --grn grn.csv --invoice invoices.csv --only-exceptions
PO ITEM STATUS ORDERED RECEIVED INVOICED QTYVAR VALVAR REASON
-----------------------------------------------------------------------------------------------
PO-1003 WGT-300 exception 20 0 5 5 125.00 Invoiced but nothing received
PO-1004 WGT-400 exception 100 90 95 5 50.00 Billed for more than was received
PO-1005 WGT-500 exception 50 60 60 0 30.00 Received more than was ordered
PO-1006 WGT-600 exception 100 70 70 0 35.00 Short delivery against the order
PO-1007 WGT-700 exception 40 40 40 0 20.00 Invoice price differs from the PO price
PO-1008 WGT-800 exception 1000 1000 1000 0 20.00 Value gap with matching quantities
PO-9999 WGT-9999 exception 0 0 10 10 50.00 Receipt or invoice with no matching purchase order line
13 lines | not_started=2 matched=3 check=1 exception=7 | exception value variance = 330.00
Install
pip install git+https://github.com/Bhavya-Dhoot/threeway
Or run it straight from a clone, with nothing installed at all:
git clone https://github.com/Bhavya-Dhoot/threeway && cd threeway
PYTHONPATH=src python -m threeway.cli --po po.csv --grn grn.csv --invoice invoices.csv
Python 3.9 or newer. Not on PyPI yet.
Usage
threeway --po PO.csv --grn GRN.csv --invoice INV.csv
[--qty-tolerance N] [--value-tolerance N] [--pct-tolerance N]
[--format table|json|csv] [--only-exceptions] [--output FILE] [--quiet]
[--uom-map FILE] [--as-of DATE] [--cutoff-days N] [--config FILE]
Column names are matched case-insensitively with the obvious aliases — po_number,
po number, po, purchase_order all work, as do item/sku, qty/quantity,
price/unit_price. If a required column is missing, it exits with the file name
and the columns it actually found, rather than guessing.
Exit codes
| Code | Meaning |
|---|---|
0 |
No exceptions. Everything reconciled. |
1 |
One or more exceptions found. |
2 |
Usage or input error — bad file, missing column, unparseable number. |
Exit 1 is deliberate: it lets you gate a pipeline on a clean reconciliation.
threeway --po po.csv --grn grn.csv --invoice inv.csv --quiet || echo "someone needs to look at this"
How a line is judged
Every line is keyed on PO number + item code. If two of your systems disagree about either, the line will not join and is reported as an exception — that is the correct answer, not a bug, and it is usually the real problem worth finding.
Then, in order:
| Status | When |
|---|---|
not_started |
Nothing received and nothing invoiced yet. |
matched |
Quantity and value variance both inside tolerance. |
check |
Outside tolerance but within a small multiple of it. Worth a look, not an alarm. |
exception |
Everything else. |
An exception carries exactly one reason, chosen first-match-wins:
- Invoiced but nothing received
- No receipt and no invoice yet
- Billed for more than was received
- Received more than was ordered
- Short delivery against the order
- Invoice price differs from the PO price
- Value gap with matching quantities
Plus Receipt or invoice with no matching purchase order line for orphans.
Tolerances
Defaults are --qty-tolerance 2, --value-tolerance 1.00, --pct-tolerance 0.5.
They exist to forgive rounding. They must not forgive real gaps — if you find
yourself widening them until the exception list is empty, you have turned the tool
off rather than fixed the data.
Units of measure
A purchase order in cases and an invoice in eaches is not a discrepancy, it is
a conversion. If your files carry an optional uom column (aliases: uom,
unit, unit_of_measure, units), point --uom-map at a JSON or TOML file
that says how to convert:
{
"global": {
"CASE": { "unit": "EA", "factor": 12 }
},
"items": {
"WGT-1300": {
"BOX": { "unit": "EA", "factor": 24 }
}
}
}
global rules apply to any item; items rules override global for a
specific item code. Which of the three files' units is treated as "the base"
is decided by majority: if two files agree on a unit and one doesn't, the
majority wins (a PO in cases against a GRN and invoice both in eaches means
eaches is the base, and the PO line gets converted). A factor that produces a
fractional quantity is kept exact as a Decimal - it is never rounded.
threeway --po po.csv --grn grn.csv --invoice inv.csv --uom-map examples/uom_map.json
If a line's units differ across files and no rule covers the conversion, that is an exception with reason "Units differ and no conversion rule applies". This is checked after the orphan check but before any of the generic quantity reasons - a unit mismatch is always the more useful thing to report.
See examples/po_units.csv, examples/grn_units.csv,
examples/invoices_units.csv, and examples/uom_map.json for a worked
example, including one line that converts cleanly and one that doesn't.
Timing and cut-off
If your files carry an optional date column (aliases: date, posted_date,
document_date, received_date, invoice_date, ISO YYYY-MM-DD), two flags
become available:
--as-of DATEignores any row dated afterDATE. This is how you reconcile a closed period as it stood at close, rather than as it stands today.--cutoff-days N(default0) downgrades an exception tocheck- with reason "Timing difference within the cut-off window" - when it is explained by a gap between the receipt date and the invoice date ofNdays or less. A gap of exactlyNdays downgrades;N + 1days does not.
threeway --po examples/po_cutoff.csv --grn examples/grn_cutoff.csv \
--invoice examples/invoices_cutoff.csv --cutoff-days 3
In examples/grn_cutoff.csv the goods are received on 2026-01-30; in
examples/invoices_cutoff.csv the invoice is dated 2026-02-02 - a 3-day
gap. Run the example without --cutoff-days and it is an exception ("Billed
for more than was received"); with --cutoff-days 3 or higher it downgrades
to check. The cut-off check never applies to an orphan line or a units
exception - those aren't timing problems.
Config file
Settings can live in a file instead of being retyped on every run:
threeway --po po.csv --grn grn.csv --invoice inv.csv --config threeway.toml
--config accepts JSON always, and TOML when running on Python 3.11+
(tomllib is part of the standard library from 3.11 onward; on 3.9/3.10, use
JSON). If --config isn't given, threeway.toml then threeway.json in the
current directory are tried automatically. A missing or malformed file exits
2 and names the file and the problem.
# threeway.toml
qty_tolerance = 5
value_tolerance = 2.00
pct_tolerance = 1.0
cutoff_days = 3
[uom_map.global.CASE]
unit = "EA"
factor = 12
A config file can also override column-name aliases, for source systems that use their own vocabulary:
{
"columns": {
"po_number": ["order_ref"],
"quantity": { "grn": ["delivered"] }
}
}
columns overrides add aliases; the built-in ones (po_number, po,
purchase_order, and so on) still work alongside them.
Precedence
For every tolerance, cutoff_days, and the uom map, highest wins first:
| Priority | Source |
|---|---|
| 1 (highest) | An explicit command-line flag (--qty-tolerance, --cutoff-days, --uom-map, ...) |
| 2 | The config file (--config, or an auto-discovered threeway.toml/threeway.json) |
| 3 (lowest) | The built-in default |
See examples/threeway.toml for a complete example.
Why the reason matters more than the variance
A single 4% variance is not one problem. It is usually a unit mismatch on some lines, a cut-off effect on others, and a genuine shortage on the rest — and until you separate them you are negotiating with a supplier, or writing off stock, on a number that is three different problems wearing one disguise.
That is why every line gets a reason rather than only a number.
Development
git clone https://github.com/Bhavya-Dhoot/threeway
cd threeway
python -m pytest # 106 tests, no install needed
Tests run straight from a clone — pythonpath is set in pyproject.toml.
See CONTRIBUTING.md for the zero-dependency rule and what
a bug-fix change is expected to include. See CHANGELOG.md
for release history.
If you would rather not run Python
The same logic exists as a spreadsheet, for the finance and ops people who need it but do not want a terminal: The Three-Way Match — an Excel workbook with the same statuses and reasons, live formulas, nothing locked.
There is also a free written guide on why operational data disagrees in the first place, and how to build a reconciliation that surfaces exceptions instead of burying them: Why Your Numbers Disagree.
Neither is required. This tool is complete on its own and always will be.
License
MIT — Bhavya Dhoot
Metadata
Release files for threeway 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| threeway-1.1.0.tar.gz | 30.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| threeway-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 52.4 kB
Release files / threeway-1.1.0.tar.gz
| Download URL | threeway-1.1.0.tar.gz |
|---|---|
| Size | 30.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
897f7ab63dfb0912776751981d2a26aee7d120de04a1f903ec37e9fa7612ab30
|
|
BLAKE2b-256 checksum How to use checksums |
a03af287fe607cb11e62a1316312c5129ea5ff33b18dbb96c1a4f5942f228168
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.8
|
Release files / threeway-1.1.0-py3-none-any.whl
| Download URL | threeway-1.1.0-py3-none-any.whl |
|---|---|
| Size | 21.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
56640a4bf2a4fde7a03c19e5f9422a1669cf6a19c2b91f6f73ba62158cf18a7d
|
|
BLAKE2b-256 checksum How to use checksums |
438176cdb47508ccd46ecaadadc5b7ea1b93dfd95aad095cb5005ae6c6da0c34
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.8
|