Skip to main content

threeway

Reconcile a purchase order against goods received against the supplier invoice, and find out which lines disagree and why.

tests License: MIT Python Tests Dependencies

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:

  1. Invoiced but nothing received
  2. No receipt and no invoice yet
  3. Billed for more than was received
  4. Received more than was ordered
  5. Short delivery against the order
  6. Invoice price differs from the PO price
  7. 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 DATE ignores any row dated after DATE. This is how you reconcile a closed period as it stood at close, rather than as it stands today.
  • --cutoff-days N (default 0) downgrades an exception to check - with reason "Timing difference within the cut-off window" - when it is explained by a gap between the receipt date and the invoice date of N days or less. A gap of exactly N days downgrades; N + 1 days 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)

Source distribution for threeway 1.1.0
File Size Uploaded
threeway-1.1.0.tar.gz 30.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for threeway 1.1.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page