diff --git a/README.md b/README.md index 5e82c93..fe712d2 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,24 @@ for f in result["findings"]: `kind` is `match`, `unsure_match` (a person should check), `only_left` or `only_right`. `details["pairs"]` lists `[left id, right id, confidence]`. Like `reconcile`, it takes paths or `Table`s; `match_files([...])` picks the pair; `match_stored(left_id, right_id, model=...)` works on stored files; and `details["weights"]` can be passed back as `weights=` to match next month's lists the same way. One analysis per call. +## Audit + +Find what doesn't add up. Send text documents with labeled amounts (invoices, statements, schedules; about 4 or more of a kind) and TrueUp learns the arithmetic each kind obeys from the documents themselves, then flags the ones that break it. Send one table and it checks its rows the same way (qty × unit price = amount), and flags repeated rows. + +```python +result = trueup.audit([f"inv-104{i}.txt" for i in range(1, 7)]) +print(result["headline"]) +# 1 of 6 documents don't add up; 0 more to review (5 laws learned). +for f in result["findings"]: + print(f["subject"], f["amount"], f["detail"]) +# inv-1045.txt 200 subtotal + tax amount = total: 4,837.84 vs 5,037.84 + +# Next month, even one invoice at a time, against the same laws: +trueup.audit(["inv-1050.txt"], weights=result["details"]["weights"]) +``` + +`audit_stored(file_ids, model=...)` audits stored files. One analysis per call. + ## Stored files, runs and saved models Files uploaded to your team stay there (you'll also see them in the dashboard). Runs on stored files are kept, and what a run learned can be saved as a model: @@ -175,7 +193,7 @@ TrueUp( The tests run in Docker against the live API: ```bash -export TRUEUP_API_KEY=tu_live_... # a key for a test team (each run uses 6 analyses) +export TRUEUP_API_KEY=tu_live_... # a key for a test team (each run uses 8 analyses) just test # or: docker compose run --rm test ``` diff --git a/src/trueup/__init__.py b/src/trueup/__init__.py index 6162051..5926096 100644 --- a/src/trueup/__init__.py +++ b/src/trueup/__init__.py @@ -232,6 +232,21 @@ def match_stored(self, left_file_id: Optional[str] = None, right_file_id: Option (``run_id``). One analysis.""" return self._stored("/v1/match", left_file_id, right_file_id, file_ids, model, None) + def audit(self, files: Sequence[TableLike], *, weights: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: + """Find what doesn't add up. Text documents (invoices, statements, 4 or more of a kind): TrueUp learns the + arithmetic each kind obeys and flags the ones that break it. One table: the same for its rows, plus repeated + rows. ``weights`` (``details["weights"]`` of an earlier audit) checks new documents against the same laws. + Counts as one analysis. + """ + if not files: + raise InvalidRequestError("Pass the documents (or one table) to audit.", 0, "invalid_request") + return self._request("POST", "/v1/audit", files=[("files", _table(f)._file()) for f in files], + data=_options(weights, None)) + + def audit_stored(self, file_ids: Sequence[str], *, model: Optional[str] = None) -> Dict[str, Any]: + """Audit files already stored in the team, by id. ``model`` applies a saved audit model. The run is kept.""" + return self._stored("/v1/audit", None, None, file_ids, model, None) + def reconcile_stored(self, left_file_id: Optional[str] = None, right_file_id: Optional[str] = None, *, file_ids: Optional[Sequence[str]] = None, model: Optional[str] = None, answers: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: diff --git a/tests/fixtures/invoices/inv-1041.txt b/tests/fixtures/invoices/inv-1041.txt new file mode 100644 index 0000000..51dd582 --- /dev/null +++ b/tests/fixtures/invoices/inv-1041.txt @@ -0,0 +1,17 @@ +INVOICE +======= + +From: Ridgeline Steel Supply +Invoice Number: INV-1041 +Date: 2026-08-03 + +Line Items +---------- +Items: + - Description 29ga roof panels | Amount $4,210.00 + - Description Ridge cap | Amount $256.80 + +Subtotal: $4,466.80 +Tax Rate: 6.00% +Tax Amount: $268.01 +Total: $4,734.81 diff --git a/tests/fixtures/invoices/inv-1042.txt b/tests/fixtures/invoices/inv-1042.txt new file mode 100644 index 0000000..892a6a7 --- /dev/null +++ b/tests/fixtures/invoices/inv-1042.txt @@ -0,0 +1,18 @@ +INVOICE +======= + +From: Ridgeline Steel Supply +Invoice Number: INV-1042 +Date: 2026-08-07 + +Line Items +---------- +Items: + - Description C-purlins 8in | Amount $6,980.40 + - Description Self-drilling screws | Amount $319.90 + - Description Delivery | Amount $125.00 + +Subtotal: $7,425.30 +Tax Rate: 6.00% +Tax Amount: $445.52 +Total: $7,870.82 diff --git a/tests/fixtures/invoices/inv-1043.txt b/tests/fixtures/invoices/inv-1043.txt new file mode 100644 index 0000000..457d146 --- /dev/null +++ b/tests/fixtures/invoices/inv-1043.txt @@ -0,0 +1,16 @@ +INVOICE +======= + +From: Ridgeline Steel Supply +Invoice Number: INV-1043 +Date: 2026-08-12 + +Line Items +---------- +Items: + - Description Anchor bolts | Amount $482.50 + +Subtotal: $482.50 +Tax Rate: 6.00% +Tax Amount: $28.95 +Total: $511.45 diff --git a/tests/fixtures/invoices/inv-1044.txt b/tests/fixtures/invoices/inv-1044.txt new file mode 100644 index 0000000..de0ebb5 --- /dev/null +++ b/tests/fixtures/invoices/inv-1044.txt @@ -0,0 +1,17 @@ +INVOICE +======= + +From: Ridgeline Steel Supply +Invoice Number: INV-1044 +Date: 2026-08-15 + +Line Items +---------- +Items: + - Description Red iron frame package | Amount $38,450.00 + - Description Engineering stamp | Amount $1,200.00 + +Subtotal: $39,650.00 +Tax Rate: 6.00% +Tax Amount: $2,379.00 +Total: $42,029.00 diff --git a/tests/fixtures/invoices/inv-1045.txt b/tests/fixtures/invoices/inv-1045.txt new file mode 100644 index 0000000..b304147 --- /dev/null +++ b/tests/fixtures/invoices/inv-1045.txt @@ -0,0 +1,17 @@ +INVOICE +======= + +From: Ridgeline Steel Supply +Invoice Number: INV-1045 +Date: 2026-08-21 + +Line Items +---------- +Items: + - Description Overhead door 12x14 | Amount $3,875.00 + - Description Walk door 3070 | Amount $689.00 + +Subtotal: $4,564.00 +Tax Rate: 6.00% +Tax Amount: $273.84 +Total: $5,037.84 diff --git a/tests/fixtures/invoices/inv-1046.txt b/tests/fixtures/invoices/inv-1046.txt new file mode 100644 index 0000000..e029e06 --- /dev/null +++ b/tests/fixtures/invoices/inv-1046.txt @@ -0,0 +1,17 @@ +INVOICE +======= + +From: Ridgeline Steel Supply +Invoice Number: INV-1046 +Date: 2026-08-28 + +Line Items +---------- +Items: + - Description Wainscot panels | Amount $1,940.00 + - Description Trim package | Amount $612.35 + +Subtotal: $2,552.35 +Tax Rate: 6.00% +Tax Amount: $153.14 +Total: $2,705.49 diff --git a/tests/test_api.py b/tests/test_api.py index 042e210..075b4b4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,6 @@ """Integration tests against the live TrueUp API. Need TRUEUP_API_KEY (and optionally TRUEUP_BASE_URL). -Each full run uses 6 analyses. Run in Docker: `just test` (or `docker compose run --rm test`). +Each full run uses 8 analyses. Run in Docker: `just test` (or `docker compose run --rm test`). """ import csv @@ -115,3 +115,16 @@ def test_match_two_lists_then_reuse_the_learning(): weights=result["details"]["weights"]) assert [p[:2] for p in again["details"]["pairs"]] == MATCHED assert again["details"]["model"]["learned"] is False + + +@live +def test_audit_six_invoices_then_one_against_the_saved_laws(): + with TrueUp() as tu: + names = [f"inv-104{i}.txt" for i in range(1, 7)] + result = tu.audit([FIXTURES / "invoices" / n for n in names]) + assert result["analysis"] == "audit" + assert [(f["subject"], f["status"], f["amount"]) for f in result["findings"]] == [("inv-1045.txt", "yes", 200)] + assert any(l["law"] == "subtotal + tax amount = total" for l in result["details"]["laws"]) + one = tu.audit([FIXTURES / "invoices" / "inv-1045.txt"], weights=result["details"]["weights"]) + assert one["details"]["model"]["learned"] is False + assert [f["subject"] for f in one["findings"]] == ["inv-1045.txt"]