Metadata-Version: 2.4
Name: threeway
Version: 1.1.0
Summary: Three-way reconciliation of purchase orders, goods received, and supplier invoices
Author: Bhavya Dhoot
License: MIT
Project-URL: Homepage, https://github.com/Bhavya-Dhoot/threeway
Project-URL: Issues, https://github.com/Bhavya-Dhoot/threeway/issues
Keywords: reconciliation,three-way-match,accounts-payable,procurement,invoice,erp
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Office/Business :: Financial :: Accounting
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Dynamic: license-file

# threeway

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

[![tests](https://github.com/Bhavya-Dhoot/threeway/actions/workflows/tests.yml/badge.svg)](https://github.com/Bhavya-Dhoot/threeway/actions/workflows/tests.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-0F766E?style=flat-square)](LICENSE)
[![Python](https://img.shields.io/badge/python-%3E%3D3.9-8A8F98?style=flat-square)](pyproject.toml)
[![Tests](https://img.shields.io/badge/tests-106%20passing-0F766E?style=flat-square)](tests)
[![Dependencies](https://img.shields.io/badge/dependencies-none-0F766E?style=flat-square)](pyproject.toml)

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.

```console
$ 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

```bash
pip install git+https://github.com/Bhavya-Dhoot/threeway
```

Or run it straight from a clone, with nothing installed at all:

```bash
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.

```bash
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:

```json
{
  "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.

```bash
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.

```bash
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:

```bash
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.

```toml
# 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:

```json
{
  "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

```bash
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`](CONTRIBUTING.md) for the zero-dependency rule and what
a bug-fix change is expected to include. See [`CHANGELOG.md`](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**](https://bhavyadhoot.gumroad.com/l/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**](https://bhavyadhoot.gumroad.com/l/numbers-disagree).

Neither is required. This tool is complete on its own and always will be.

## License

[MIT](LICENSE) — Bhavya Dhoot
