invcheck 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ typescript/dist/
3
+ typescript/package-lock.json
4
+ python/.venv/
5
+ python/**/__pycache__/
6
+ python/.pytest_cache/
7
+ *.py[cod]
8
+ .env
9
+ .DS_Store
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: invcheck
3
+ Version: 1.0.0
4
+ Summary: Deterministic Arithmetic, Tax & E-Invoice JSON Verifier and Auto-Repair Engine for Docling, MarkItDown, and VLM Extractors
5
+ Author: Rohan & invcheck Contributors
6
+ License-Expression: MIT
7
+ Keywords: deterministic-math,docling,ein,en16931,iban,invoice,markitdown,mcp,reducto,tax-calculation,vat,vlm
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Financial and Insurance Industry
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+
21
+ # invcheck
22
+
23
+ <p align="center">
24
+ <strong>Deterministic Arithmetic, Tax & E-Invoice JSON Verifier & Auto-Repair Engine</strong><br>
25
+ <em>The missing 0-dependency MIT verification primitive for Docling, MarkItDown, Textract, and VLM Extractors</em>
26
+ </p>
27
+
28
+ <p align="center">
29
+ <a href="https://npmjs.com/package/invcheck"><img src="https://img.shields.io/npm/v/invcheck?color=blue" alt="npm version"></a>
30
+ <a href="https://pypi.org/project/invcheck"><img src="https://img.shields.io/pypi/v/invcheck?color=blue" alt="pypi version"></a>
31
+ <a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
32
+ <a href="#dependencies"><img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="dependencies"></a>
33
+ <a href="#speed"><img src="https://img.shields.io/badge/latency-%3C0.2ms-orange" alt="latency"></a>
34
+ </p>
35
+
36
+ ---
37
+
38
+ ## Why invcheck?
39
+
40
+ Every modern document extraction pipeline (using **IBM Docling**, **Microsoft MarkItDown**, **LiteParse**, or **GPT-4o / Claude Sonnet** vision prompts) suffers from a critical flaw: **models cannot guarantee exact arithmetic**.
41
+
42
+ * Line items drift by off-by-one cent rounding errors ($0.01).
43
+ * Subtotals and tax rates hallucinate mismatches.
44
+ * Extracted IBANs, VAT numbers, and EINs contain dropped or transposed characters.
45
+
46
+ **`invcheck`** provides a sub-millisecond, zero-dependency, deterministic financial boundary for your extraction pipeline. Available synchronously in both **TypeScript** and **Python**.
47
+
48
+ | Feature | `invcheck` | Reducto | strutex | Manual Regex / Scripts |
49
+ | :--- | :---: | :---: | :---: | :---: |
50
+ | **License** | **MIT (Free forever)** | Proprietary SaaS ($$$) | GPL-3.0 (Viral) | Custom |
51
+ | **Dependencies** | **0 (Zero)** | Heavy API SDK | Python dependencies | None |
52
+ | **Runtime** | **Sub-millisecond Local** | Cloud API (100-500ms) | Local (Python only) | Fast |
53
+ | **TypeScript + Python** | **Yes (Full parity)** | Python/REST only | Python only | Fragmented |
54
+ | **EN 16931 Banker's Rounding** | **Yes** | Cloud blackbox | Partial | Rarely |
55
+ | **Auto-Repair / Healing Mode** | **Yes (Diff + Confidence)** | Cloud blackbox | No | No |
56
+ | **IBAN / VAT / EIN Checksums** | **Yes (ISO 7064, Mod 89/97)**| Partial | No | Fragile |
57
+
58
+ ---
59
+
60
+ ## Quickstart
61
+
62
+ ### Node.js / TypeScript
@@ -0,0 +1,42 @@
1
+ # invcheck
2
+
3
+ <p align="center">
4
+ <strong>Deterministic Arithmetic, Tax & E-Invoice JSON Verifier & Auto-Repair Engine</strong><br>
5
+ <em>The missing 0-dependency MIT verification primitive for Docling, MarkItDown, Textract, and VLM Extractors</em>
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://npmjs.com/package/invcheck"><img src="https://img.shields.io/npm/v/invcheck?color=blue" alt="npm version"></a>
10
+ <a href="https://pypi.org/project/invcheck"><img src="https://img.shields.io/pypi/v/invcheck?color=blue" alt="pypi version"></a>
11
+ <a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
12
+ <a href="#dependencies"><img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="dependencies"></a>
13
+ <a href="#speed"><img src="https://img.shields.io/badge/latency-%3C0.2ms-orange" alt="latency"></a>
14
+ </p>
15
+
16
+ ---
17
+
18
+ ## Why invcheck?
19
+
20
+ Every modern document extraction pipeline (using **IBM Docling**, **Microsoft MarkItDown**, **LiteParse**, or **GPT-4o / Claude Sonnet** vision prompts) suffers from a critical flaw: **models cannot guarantee exact arithmetic**.
21
+
22
+ * Line items drift by off-by-one cent rounding errors ($0.01).
23
+ * Subtotals and tax rates hallucinate mismatches.
24
+ * Extracted IBANs, VAT numbers, and EINs contain dropped or transposed characters.
25
+
26
+ **`invcheck`** provides a sub-millisecond, zero-dependency, deterministic financial boundary for your extraction pipeline. Available synchronously in both **TypeScript** and **Python**.
27
+
28
+ | Feature | `invcheck` | Reducto | strutex | Manual Regex / Scripts |
29
+ | :--- | :---: | :---: | :---: | :---: |
30
+ | **License** | **MIT (Free forever)** | Proprietary SaaS ($$$) | GPL-3.0 (Viral) | Custom |
31
+ | **Dependencies** | **0 (Zero)** | Heavy API SDK | Python dependencies | None |
32
+ | **Runtime** | **Sub-millisecond Local** | Cloud API (100-500ms) | Local (Python only) | Fast |
33
+ | **TypeScript + Python** | **Yes (Full parity)** | Python/REST only | Python only | Fragmented |
34
+ | **EN 16931 Banker's Rounding** | **Yes** | Cloud blackbox | Partial | Rarely |
35
+ | **Auto-Repair / Healing Mode** | **Yes (Diff + Confidence)** | Cloud blackbox | No | No |
36
+ | **IBAN / VAT / EIN Checksums** | **Yes (ISO 7064, Mod 89/97)**| Partial | No | Fragile |
37
+
38
+ ---
39
+
40
+ ## Quickstart
41
+
42
+ ### Node.js / TypeScript
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "invcheck"
7
+ version = "1.0.0"
8
+ description = "Deterministic Arithmetic, Tax & E-Invoice JSON Verifier and Auto-Repair Engine for Docling, MarkItDown, and VLM Extractors"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Rohan & invcheck Contributors" }
14
+ ]
15
+ keywords = [
16
+ "invoice",
17
+ "docling",
18
+ "markitdown",
19
+ "vlm",
20
+ "tax-calculation",
21
+ "reducto",
22
+ "iban",
23
+ "ein",
24
+ "vat",
25
+ "en16931",
26
+ "deterministic-math",
27
+ "mcp"
28
+ ]
29
+ classifiers = [
30
+ "Development Status :: 5 - Production/Stable",
31
+ "Intended Audience :: Developers",
32
+ "Intended Audience :: Financial and Insurance Industry",
33
+ "License :: OSI Approved :: MIT License",
34
+ "Programming Language :: Python :: 3",
35
+ "Programming Language :: Python :: 3.9",
36
+ "Programming Language :: Python :: 3.10",
37
+ "Programming Language :: Python :: 3.11",
38
+ "Programming Language :: Python :: 3.12",
39
+ "Topic :: Office/Business :: Financial :: Accounting",
40
+ ]
41
+ dependencies = []
42
+
43
+ [project.scripts]
44
+ invcheck = "invcheck.cli:main"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/invcheck"]
@@ -0,0 +1,51 @@
1
+ from typing import Any, Dict, Optional, Union
2
+ from .types import NormalizedInvoice, VerificationReport, RepairResult
3
+ from .normalizer import normalize_invoice
4
+ from .arithmetic import verify_normalized_invoice
5
+ from .repair import repair_invoice
6
+ from .checksums import (
7
+ validate_iban,
8
+ validate_german_vat,
9
+ validate_french_vat,
10
+ validate_uk_vat,
11
+ validate_us_ein,
12
+ validate_abn,
13
+ )
14
+
15
+ __version__ = "1.0.0"
16
+
17
+ def verify_invoice(
18
+ invoice_data: Union[Dict[str, Any], NormalizedInvoice],
19
+ tolerance: float = 0.01,
20
+ rounding_mode: str = "HALF_UP",
21
+ strict_identifiers: bool = True,
22
+ ) -> VerificationReport:
23
+ """
24
+ Primary API function to verify any extracted invoice JSON.
25
+ """
26
+ if isinstance(invoice_data, NormalizedInvoice):
27
+ norm = invoice_data
28
+ else:
29
+ norm = normalize_invoice(invoice_data)
30
+
31
+ return verify_normalized_invoice(
32
+ norm,
33
+ tolerance=tolerance,
34
+ rounding_mode=rounding_mode,
35
+ strict_identifiers=strict_identifiers,
36
+ )
37
+
38
+ __all__ = [
39
+ "verify_invoice",
40
+ "repair_invoice",
41
+ "normalize_invoice",
42
+ "validate_iban",
43
+ "validate_german_vat",
44
+ "validate_french_vat",
45
+ "validate_uk_vat",
46
+ "validate_us_ein",
47
+ "validate_abn",
48
+ "NormalizedInvoice",
49
+ "VerificationReport",
50
+ "RepairResult",
51
+ ]
@@ -0,0 +1,188 @@
1
+ from decimal import Decimal
2
+ from typing import List, Optional
3
+ from .types import NormalizedInvoice, ValidationError, VerificationReport, VerificationSummary
4
+ from .decimal_ops import to_decimal, round_decimal
5
+ from .checksums import validate_iban, validate_german_vat, validate_uk_vat, validate_us_ein, validate_abn
6
+
7
+
8
+ def verify_normalized_invoice(
9
+ inv: NormalizedInvoice,
10
+ tolerance: float = 0.01,
11
+ rounding_mode: str = "HALF_UP",
12
+ strict_identifiers: bool = True
13
+ ) -> VerificationReport:
14
+ tol = Decimal(str(tolerance))
15
+ errors: List[ValidationError] = []
16
+ warnings: List[ValidationError] = []
17
+
18
+ # 1. Line Item Math
19
+ computed_subtotal = Decimal("0.00")
20
+ for idx, item in enumerate(inv.items):
21
+ qty = to_decimal(item.quantity)
22
+ price = to_decimal(item.unit_price)
23
+ line_total = to_decimal(item.line_total)
24
+
25
+ expected = qty * price
26
+ if item.discount_rate and item.discount_rate > 0:
27
+ expected *= (Decimal("1.0") - (to_decimal(item.discount_rate) / Decimal("100.0")))
28
+ elif item.discount_amount and item.discount_amount > 0:
29
+ expected -= to_decimal(item.discount_amount)
30
+
31
+ expected = round_decimal(expected, 2, rounding_mode)
32
+
33
+ if abs(expected - line_total) > tol:
34
+ errors.append(
35
+ ValidationError(
36
+ code="LINE_ITEM_TOTAL_MISMATCH",
37
+ field=f"items[{idx}].line_total",
38
+ severity="ERROR",
39
+ message=f"Line item #{idx + 1} ({item.description or 'Item'}) total {line_total} != qty({item.quantity}) * price({item.unit_price}) = {expected}",
40
+ expected=float(expected),
41
+ actual=float(line_total),
42
+ discrepancy=float(line_total - expected),
43
+ )
44
+ )
45
+ computed_subtotal += line_total
46
+
47
+ computed_subtotal = round_decimal(computed_subtotal, 2, rounding_mode)
48
+
49
+ # 2. Subtotal Validation
50
+ if inv.subtotal is not None:
51
+ subtotal = round_decimal(to_decimal(inv.subtotal), 2, rounding_mode)
52
+ if abs(computed_subtotal - subtotal) > tol:
53
+ errors.append(
54
+ ValidationError(
55
+ code="SUBTOTAL_MISMATCH",
56
+ field="subtotal",
57
+ severity="ERROR",
58
+ message=f"Extracted subtotal {subtotal} != sum of line totals {computed_subtotal}",
59
+ expected=float(computed_subtotal),
60
+ actual=float(subtotal),
61
+ discrepancy=float(subtotal - computed_subtotal),
62
+ )
63
+ )
64
+
65
+ # 3. Tax Breakdown
66
+ computed_tax_total = Decimal("0.00")
67
+ if inv.tax_brackets:
68
+ for idx, tb in enumerate(inv.tax_brackets):
69
+ basis = to_decimal(tb.basis)
70
+ rate = to_decimal(tb.rate)
71
+ act_tax = round_decimal(to_decimal(tb.amount), 2, rounding_mode)
72
+ exp_tax = round_decimal(basis * (rate / Decimal("100.0")), 2, rounding_mode)
73
+
74
+ if abs(exp_tax - act_tax) > tol:
75
+ errors.append(
76
+ ValidationError(
77
+ code="TAX_BRACKET_MISMATCH",
78
+ field=f"tax_brackets[{idx}].amount",
79
+ severity="ERROR",
80
+ message=f"Tax bracket {tb.rate}% amount {act_tax} != basis({tb.basis}) * rate = {exp_tax}",
81
+ expected=float(exp_tax),
82
+ actual=float(act_tax),
83
+ discrepancy=float(act_tax - exp_tax),
84
+ )
85
+ )
86
+ computed_tax_total += act_tax
87
+ elif inv.tax_total is not None:
88
+ computed_tax_total = round_decimal(to_decimal(inv.tax_total), 2, rounding_mode)
89
+
90
+ computed_tax_total = round_decimal(computed_tax_total, 2, rounding_mode)
91
+
92
+ # 4. Grand Total
93
+ base_subtotal = to_decimal(inv.subtotal) if inv.subtotal is not None else computed_subtotal
94
+ computed_grand_total = base_subtotal + computed_tax_total
95
+
96
+ if inv.shipping_amount:
97
+ computed_grand_total += to_decimal(inv.shipping_amount)
98
+ if inv.discount_amount:
99
+ computed_grand_total -= to_decimal(inv.discount_amount)
100
+ computed_grand_total = round_decimal(computed_grand_total, 2, rounding_mode)
101
+
102
+ if inv.grand_total is not None:
103
+ ext_grand = round_decimal(to_decimal(inv.grand_total), 2, rounding_mode)
104
+ if abs(computed_grand_total - ext_grand) > tol:
105
+ errors.append(
106
+ ValidationError(
107
+ code="GRAND_TOTAL_MISMATCH",
108
+ field="grand_total",
109
+ severity="ERROR",
110
+ message=f"Extracted grand total {ext_grand} != computed total {computed_grand_total}",
111
+ expected=float(computed_grand_total),
112
+ actual=float(ext_grand),
113
+ discrepancy=float(ext_grand - computed_grand_total),
114
+ )
115
+ )
116
+
117
+ # 5. Identifier Checksums
118
+ if strict_identifiers:
119
+ ids = inv.identifiers
120
+ if ids.iban and not validate_iban(ids.iban):
121
+ errors.append(
122
+ ValidationError(
123
+ code="INVALID_IBAN_CHECKSUM",
124
+ field="identifiers.iban",
125
+ severity="ERROR",
126
+ message=f"IBAN '{ids.iban}' failed ISO 7064 Mod 97-10 checksum",
127
+ actual=ids.iban,
128
+ )
129
+ )
130
+ if ids.vat_id:
131
+ cvat = ids.vat_id.upper().replace(" ", "").replace("-", "")
132
+ if cvat.startswith("DE") and not validate_german_vat(cvat):
133
+ errors.append(
134
+ ValidationError(
135
+ code="INVALID_GERMAN_VAT",
136
+ field="identifiers.vat_id",
137
+ severity="ERROR",
138
+ message=f"German VAT '{ids.vat_id}' failed Mod 11, 10 checksum",
139
+ actual=ids.vat_id,
140
+ )
141
+ )
142
+ elif cvat.startswith("GB") and not validate_uk_vat(cvat):
143
+ errors.append(
144
+ ValidationError(
145
+ code="INVALID_UK_VAT",
146
+ field="identifiers.vat_id",
147
+ severity="ERROR",
148
+ message=f"UK VAT '{ids.vat_id}' failed Mod 97 checksum",
149
+ actual=ids.vat_id,
150
+ )
151
+ )
152
+ if ids.ein and not validate_us_ein(ids.ein):
153
+ warnings.append(
154
+ ValidationError(
155
+ code="INVALID_US_EIN_PREFIX",
156
+ field="identifiers.ein",
157
+ severity="WARNING",
158
+ message=f"US EIN '{ids.ein}' has unrecognized IRS campus prefix",
159
+ actual=ids.ein,
160
+ )
161
+ )
162
+ if ids.abn and not validate_abn(ids.abn):
163
+ errors.append(
164
+ ValidationError(
165
+ code="INVALID_ABN_CHECKSUM",
166
+ field="identifiers.abn",
167
+ severity="ERROR",
168
+ message=f"Australian ABN '{ids.abn}' failed Mod 89 checksum",
169
+ actual=ids.abn,
170
+ )
171
+ )
172
+
173
+ penalty = len(errors) * 25 + len(warnings) * 5
174
+ score = max(0, min(100, 100 - penalty))
175
+
176
+ return VerificationReport(
177
+ is_valid=len(errors) == 0,
178
+ score=score,
179
+ errors=errors,
180
+ warnings=warnings,
181
+ summary=VerificationSummary(
182
+ computed_subtotal=float(computed_subtotal),
183
+ computed_tax_total=float(computed_tax_total),
184
+ computed_grand_total=float(computed_grand_total),
185
+ extracted_grand_total=inv.grand_total,
186
+ reconciled=len(errors) == 0,
187
+ ),
188
+ )
@@ -0,0 +1,101 @@
1
+ import re
2
+
3
+ # 1. ISO 7064 Mod 97-10 Checksum (IBAN)
4
+ def validate_iban(iban_str: str) -> bool:
5
+ clean = re.sub(r"[\s-]", "", iban_str.upper())
6
+ if not re.match(r"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$", clean):
7
+ return False
8
+
9
+ rearranged = clean[4:] + clean[:4]
10
+ numeric = ""
11
+ for ch in rearranged:
12
+ if ch.isalpha():
13
+ numeric += str(ord(ch) - 55)
14
+ else:
15
+ numeric += ch
16
+
17
+ try:
18
+ return int(numeric) % 97 == 1
19
+ except ValueError:
20
+ return False
21
+
22
+
23
+ # 2. German VAT (USt-IdNr) ISO 7064 Mod 11, 10
24
+ def validate_german_vat(vat_str: str) -> bool:
25
+ clean = re.sub(r"[\s-]", "", vat_str.upper())
26
+ if not re.match(r"^DE[0-9]{9}$", clean):
27
+ return False
28
+ digits = clean[2:]
29
+
30
+ product = 10
31
+ for i in range(8):
32
+ d = int(digits[i])
33
+ s = (d + product) % 10
34
+ if s == 0:
35
+ s = 10
36
+ product = (2 * s) % 11
37
+
38
+ check = 11 - product
39
+ if check == 10:
40
+ check = 0
41
+ if check == 11:
42
+ check = 0
43
+
44
+ return check == int(digits[8])
45
+
46
+
47
+ # 3. French TVA Check
48
+ def validate_french_vat(vat_str: str) -> bool:
49
+ clean = re.sub(r"[\s-]", "", vat_str.upper())
50
+ if not re.match(r"^FR[A-Z0-9]{2}[0-9]{9}$", clean):
51
+ return False
52
+ key = clean[2:4]
53
+ siren = clean[4:]
54
+ if key.isdigit():
55
+ return int(key) == (12 + 3 * (int(siren) % 97)) % 97
56
+ return True
57
+
58
+
59
+ # 4. UK VAT (GB + 9 digits) Mod 97 / Mod 9755
60
+ def validate_uk_vat(vat_str: str) -> bool:
61
+ clean = re.sub(r"[\s-]", "", vat_str.upper())
62
+ if not re.match(r"^GB[0-9]{9}$", clean):
63
+ return False
64
+ digits = [int(ch) for ch in clean[2:]]
65
+ weights = [8, 7, 6, 5, 4, 3, 2]
66
+
67
+ s = sum(digits[i] * weights[i] for i in range(7))
68
+ check = digits[7] * 10 + digits[8]
69
+ c1 = (97 - (s % 97)) % 97
70
+ c2 = (c1 + 55) % 97
71
+ return check in (c1, c2)
72
+
73
+
74
+ # 5. US EIN Prefix Validation
75
+ VALID_EIN_PREFIXES = {
76
+ "01", "02", "03", "04", "05", "06", "10", "11", "12", "13", "14", "15", "16",
77
+ "20", "21", "22", "23", "24", "25", "26", "27", "30", "31", "32", "33", "34",
78
+ "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47",
79
+ "48", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61",
80
+ "62", "63", "64", "65", "66", "67", "68", "71", "72", "73", "74", "75", "76",
81
+ "77", "80", "81", "82", "83", "84", "85", "86", "87", "88", "90", "91", "92",
82
+ "93", "94", "95", "98", "99"
83
+ }
84
+
85
+ def validate_us_ein(ein_str: str) -> bool:
86
+ clean = re.sub(r"[\s-]", "", ein_str)
87
+ if not re.match(r"^[0-9]{9}$", clean):
88
+ return False
89
+ return clean[:2] in VALID_EIN_PREFIXES
90
+
91
+
92
+ # 6. Australian ABN Modulo 89
93
+ def validate_abn(abn_str: str) -> bool:
94
+ clean = re.sub(r"[\s-]", "", abn_str)
95
+ if not re.match(r"^[0-9]{11}$", clean):
96
+ return False
97
+ digits = [int(c) for c in clean]
98
+ weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
99
+
100
+ s = (digits[0] - 1) * weights[0] + sum(digits[i] * weights[i] for i in range(1, 11))
101
+ return s % 89 == 0
@@ -0,0 +1,63 @@
1
+ import sys
2
+ import json
3
+ import argparse
4
+ from .normalizer import normalize_invoice
5
+ from .arithmetic import verify_normalized_invoice
6
+ from .repair import repair_invoice
7
+
8
+
9
+ def main() -> None:
10
+ parser = argparse.ArgumentParser(description="invcheck: Deterministic E-Invoice Arithmetic & Checksum Verifier")
11
+ parser.add_argument("file", help="Path to invoice JSON file")
12
+ parser.add_argument("--repair", action="store_true", help="Auto-repair arithmetic and rounding discrepancies")
13
+ parser.add_argument("--out", help="Output path for repaired JSON")
14
+ parser.add_argument("--tolerance", type=float, default=0.01, help="Rounding tolerance threshold (default: 0.01)")
15
+ parser.add_argument("--quiet", action="store_true", help="Output JSON only")
16
+
17
+ args = parser.parse_args()
18
+
19
+ try:
20
+ with open(args.file, "r", encoding="utf-8") as f:
21
+ raw_data = json.load(f)
22
+ except Exception as e:
23
+ print(f"[invcheck error] Could not read JSON: {e}", file=sys.stderr)
24
+ sys.exit(1)
25
+
26
+ if args.repair:
27
+ res = repair_invoice(raw_data, tolerance=args.tolerance)
28
+ if args.out:
29
+ with open(args.out, "w", encoding="utf-8") as f:
30
+ json.dump(res.repaired, f, indent=2)
31
+ if not args.quiet:
32
+ print(f"✓ Healed JSON written to {args.out}")
33
+ else:
34
+ print(json.dumps(res.repaired, indent=2))
35
+ sys.exit(0)
36
+
37
+ norm = normalize_invoice(raw_data)
38
+ rep = verify_normalized_invoice(norm, tolerance=args.tolerance)
39
+
40
+ if args.quiet:
41
+ print(json.dumps({
42
+ "isValid": rep.is_valid,
43
+ "score": rep.score,
44
+ "errors": [e.__dict__ for e in rep.errors],
45
+ "summary": rep.summary.__dict__
46
+ }, indent=2))
47
+ else:
48
+ status = "✓ VALID" if rep.is_valid else "✗ DISCREPANCIES DETECTED"
49
+ print(f"\n=== invcheck Verification Report ===")
50
+ print(f"Status: {status}")
51
+ print(f"Score: {rep.score}/100")
52
+ print(f"Subtotal: {rep.summary.computed_subtotal} | Grand Total: {rep.summary.computed_grand_total}")
53
+
54
+ if rep.errors:
55
+ print(f"\nErrors ({len(rep.errors)}):")
56
+ for e in rep.errors:
57
+ print(f" ✗ [{e.code}] {e.field}: {e.message}")
58
+
59
+ sys.exit(0 if rep.is_valid else 1)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
@@ -0,0 +1,24 @@
1
+ from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN
2
+ from typing import Union, Optional
3
+
4
+
5
+ def to_decimal(val: Union[int, float, str, Decimal, None]) -> Decimal:
6
+ if val is None:
7
+ return Decimal("0.00")
8
+ if isinstance(val, Decimal):
9
+ return val
10
+ try:
11
+ # Convert floats via str to avoid IEEE 754 precision artifacts
12
+ return Decimal(str(val))
13
+ except Exception:
14
+ return Decimal("0.00")
15
+
16
+
17
+ def round_decimal(
18
+ val: Decimal,
19
+ decimals: int = 2,
20
+ mode: str = "HALF_UP"
21
+ ) -> Decimal:
22
+ rounding = ROUND_HALF_UP if mode == "HALF_UP" else ROUND_HALF_EVEN
23
+ target = Decimal("10") ** -decimals
24
+ return val.quantize(target, rounding=rounding)
@@ -0,0 +1,53 @@
1
+ from typing import Any, Dict
2
+ from .normalizer import normalize_invoice
3
+ from .arithmetic import verify_normalized_invoice
4
+ from .repair import repair_invoice
5
+
6
+ MCP_TOOLS = [
7
+ {
8
+ "name": "invcheck_verify",
9
+ "description": "Verifies invoice extraction JSON for arithmetic correctness and checksums.",
10
+ "inputSchema": {
11
+ "type": "object",
12
+ "properties": {
13
+ "invoice_json": {"type": "object"},
14
+ "tolerance": {"type": "number", "default": 0.01}
15
+ },
16
+ "required": ["invoice_json"]
17
+ }
18
+ },
19
+ {
20
+ "name": "invcheck_repair",
21
+ "description": "Auto-repairs rounding drift and mismatched totals in extracted invoice JSON.",
22
+ "inputSchema": {
23
+ "type": "object",
24
+ "properties": {
25
+ "invoice_json": {"type": "object"}
26
+ },
27
+ "required": ["invoice_json"]
28
+ }
29
+ }
30
+ ]
31
+
32
+ def handle_mcp_call(tool_name: str, args: Dict[str, Any]) -> Any:
33
+ invoice_data = args.get("invoice_json", {})
34
+ tolerance = float(args.get("tolerance", 0.01))
35
+
36
+ if tool_name == "invcheck_verify":
37
+ norm = normalize_invoice(invoice_data)
38
+ rep = verify_normalized_invoice(norm, tolerance=tolerance)
39
+ return {
40
+ "isValid": rep.is_valid,
41
+ "score": rep.score,
42
+ "errors": [e.__dict__ for e in rep.errors],
43
+ "warnings": [w.__dict__ for w in rep.warnings],
44
+ "summary": rep.summary.__dict__
45
+ }
46
+ elif tool_name == "invcheck_repair":
47
+ res = repair_invoice(invoice_data, tolerance=tolerance)
48
+ return {
49
+ "repaired": res.repaired,
50
+ "changes": [c.__dict__ for c in res.changes],
51
+ "wasRepaired": res.was_repaired
52
+ }
53
+ raise ValueError(f"Unknown MCP tool: {tool_name}")
@@ -0,0 +1,124 @@
1
+ import re
2
+ from typing import Any, Dict, List, Optional
3
+ from .types import NormalizedInvoice, NormalizedLineItem, NormalizedTaxBracket, Identifiers
4
+
5
+
6
+ def parse_clean_number(val: Any) -> Optional[float]:
7
+ if val is None or val == "":
8
+ return None
9
+ if isinstance(val, (int, float)):
10
+ return float(val)
11
+
12
+ s = str(val).strip()
13
+ if not s:
14
+ return None
15
+
16
+ cleaned = re.sub(r"[^0-9.,-]", "", s)
17
+ if "," in cleaned and "." in cleaned:
18
+ if cleaned.rfind(",") > cleaned.rfind("."):
19
+ cleaned = cleaned.replace(".", "").replace(",", ".")
20
+ else:
21
+ cleaned = cleaned.replace(",", "")
22
+ elif "," in cleaned and "." not in cleaned:
23
+ cleaned = cleaned.replace(",", ".")
24
+
25
+ try:
26
+ return float(cleaned)
27
+ except ValueError:
28
+ return None
29
+
30
+
31
+ def find_value(obj: Dict[str, Any], aliases: List[str]) -> Any:
32
+ lower_map = {re.sub(r"[^a-z0-9]", "", k.lower()): v for k, v in obj.items()}
33
+ for alias in aliases:
34
+ clean_alias = re.sub(r"[^a-z0-9]", "", alias.lower())
35
+ if clean_alias in lower_map:
36
+ v = lower_map[clean_alias]
37
+ if isinstance(v, dict) and "value" in v:
38
+ return v["value"]
39
+ return v
40
+ return None
41
+
42
+
43
+ def normalize_invoice(raw_input: Dict[str, Any]) -> NormalizedInvoice:
44
+ root = raw_input
45
+ for env in ["invoice", "document", "data", "extraction", "result"]:
46
+ if env in root and isinstance(root[env], dict):
47
+ root = root[env]
48
+ break
49
+
50
+ # 1. Line Items
51
+ items_raw = find_value(root, ["items", "line_items", "lines", "LineItem", "table_rows", "expenses"]) or []
52
+ items: List[NormalizedLineItem] = []
53
+
54
+ if isinstance(items_raw, list):
55
+ for row in items_raw:
56
+ if not isinstance(row, dict):
57
+ continue
58
+ qty = parse_clean_number(find_value(row, ["quantity", "qty", "count", "units", "Quantity"])) or 1.0
59
+ price = parse_clean_number(find_value(row, ["unit_price", "unitPrice", "price", "rate", "UnitPrice"])) or 0.0
60
+ total = parse_clean_number(find_value(row, ["line_total", "total", "amount", "TotalPrice", "extended_price"]))
61
+ if total is None:
62
+ total = qty * price
63
+
64
+ items.append(
65
+ NormalizedLineItem(
66
+ id=str(find_value(row, ["id", "sku", "item_id"]) or ""),
67
+ description=str(find_value(row, ["description", "desc", "name", "item"]) or ""),
68
+ quantity=qty,
69
+ unit_price=price,
70
+ line_total=total,
71
+ discount_rate=parse_clean_number(find_value(row, ["discount_rate", "discount_pct"])),
72
+ discount_amount=parse_clean_number(find_value(row, ["discount_amount", "discount"])),
73
+ tax_rate=parse_clean_number(find_value(row, ["tax_rate", "tax_pct", "vat_rate"])),
74
+ tax_amount=parse_clean_number(find_value(row, ["tax_amount", "tax", "vat"])),
75
+ raw=row,
76
+ )
77
+ )
78
+
79
+ # 2. Document Totals
80
+ subtotal = parse_clean_number(find_value(root, ["subtotal", "sub_total", "net_amount", "SubTotal"]))
81
+ tax_total = parse_clean_number(find_value(root, ["tax_total", "tax", "total_tax", "vat_amount", "TotalTax"]))
82
+ grand_total = parse_clean_number(find_value(root, ["grand_total", "total", "total_amount", "gross_amount", "InvoiceTotal"]))
83
+ shipping = parse_clean_number(find_value(root, ["shipping", "shipping_fee", "freight"]))
84
+ discount = parse_clean_number(find_value(root, ["discount", "total_discount"]))
85
+ amount_paid = parse_clean_number(find_value(root, ["amount_paid", "paid"]))
86
+ amount_due = parse_clean_number(find_value(root, ["amount_due", "balance_due", "due"]))
87
+
88
+ # 3. Tax Breakdown
89
+ raw_taxes = find_value(root, ["taxes", "tax_breakdown", "tax_details"]) or []
90
+ brackets: List[NormalizedTaxBracket] = []
91
+ if isinstance(raw_taxes, list):
92
+ for tb in raw_taxes:
93
+ if isinstance(tb, dict):
94
+ r = parse_clean_number(find_value(tb, ["rate", "percentage"])) or 0.0
95
+ b = parse_clean_number(find_value(tb, ["basis", "taxable_amount"])) or 0.0
96
+ a = parse_clean_number(find_value(tb, ["amount", "tax_amount"])) or ((b * r) / 100.0)
97
+ brackets.append(NormalizedTaxBracket(rate=r, basis=b, amount=a))
98
+
99
+ # 4. Identifiers
100
+ iban = find_value(root, ["iban", "bank_account", "IBAN"])
101
+ vat_id = find_value(root, ["vat_id", "vat_number", "tax_id", "ust_id"])
102
+ ein = find_value(root, ["ein", "federal_tax_id", "us_ein"])
103
+ abn = find_value(root, ["abn", "australian_business_number"])
104
+
105
+ return NormalizedInvoice(
106
+ invoice_id=str(find_value(root, ["invoice_id", "invoice_number", "InvoiceId"]) or ""),
107
+ currency=str(find_value(root, ["currency", "currency_code"]) or ""),
108
+ subtotal=subtotal,
109
+ tax_total=tax_total,
110
+ grand_total=grand_total,
111
+ shipping_amount=shipping,
112
+ discount_amount=discount,
113
+ amount_paid=amount_paid,
114
+ amount_due=amount_due,
115
+ items=items,
116
+ tax_brackets=brackets,
117
+ identifiers=Identifiers(
118
+ iban=str(iban) if iban else None,
119
+ vat_id=str(vat_id) if vat_id else None,
120
+ ein=str(ein) if ein else None,
121
+ abn=str(abn) if abn else None,
122
+ ),
123
+ raw=root,
124
+ )
File without changes
@@ -0,0 +1,138 @@
1
+ import copy
2
+ from decimal import Decimal
3
+ from typing import Any, Dict, List
4
+ from .types import FieldChange, RepairResult
5
+ from .decimal_ops import to_decimal, round_decimal
6
+ from .normalizer import normalize_invoice
7
+
8
+
9
+ def repair_invoice(
10
+ raw_input: Dict[str, Any],
11
+ tolerance: float = 0.01,
12
+ rounding_mode: str = "HALF_UP"
13
+ ) -> RepairResult:
14
+ norm = normalize_invoice(raw_input)
15
+ changes: List[FieldChange] = []
16
+ tol = Decimal(str(tolerance))
17
+ repaired = copy.deepcopy(raw_input)
18
+
19
+ # 1. Line Items Repair
20
+ running_subtotal = Decimal("0.00")
21
+ for idx, item in enumerate(norm.items):
22
+ qty = to_decimal(item.quantity)
23
+ price = to_decimal(item.unit_price)
24
+ current_total = to_decimal(item.line_total)
25
+
26
+ exp = qty * price
27
+ if item.discount_rate and item.discount_rate > 0:
28
+ exp *= (Decimal("1.0") - (to_decimal(item.discount_rate) / Decimal("100.0")))
29
+ elif item.discount_amount:
30
+ exp -= to_decimal(item.discount_amount)
31
+ exp = round_decimal(exp, 2, rounding_mode)
32
+
33
+ if abs(exp - current_total) > tol:
34
+ diff = abs(exp - current_total)
35
+ conf = 0.99 if diff <= Decimal("0.03") else 0.85
36
+ changes.append(
37
+ FieldChange(
38
+ field=f"items[{idx}].line_total",
39
+ old_value=item.line_total,
40
+ new_value=float(exp),
41
+ reason=f"Reconciled line_total = qty({item.quantity}) * price({item.unit_price})",
42
+ confidence=conf,
43
+ )
44
+ )
45
+ item.line_total = float(exp)
46
+ _update_in_payload(repaired, ["items", "line_items", "lines"], idx, ["line_total", "total", "amount"], float(exp))
47
+
48
+ running_subtotal += to_decimal(item.line_total)
49
+
50
+ running_subtotal = round_decimal(running_subtotal, 2, rounding_mode)
51
+
52
+ # 2. Subtotal Repair
53
+ if norm.subtotal is not None:
54
+ cur_sub = round_decimal(to_decimal(norm.subtotal), 2, rounding_mode)
55
+ if abs(cur_sub - running_subtotal) > tol:
56
+ changes.append(
57
+ FieldChange(
58
+ field="subtotal",
59
+ old_value=norm.subtotal,
60
+ new_value=float(running_subtotal),
61
+ reason="Recalculated subtotal as exact sum of line totals",
62
+ confidence=0.98,
63
+ )
64
+ )
65
+ norm.subtotal = float(running_subtotal)
66
+ _set_in_payload(repaired, ["subtotal", "sub_total", "SubTotal"], float(running_subtotal))
67
+ else:
68
+ norm.subtotal = float(running_subtotal)
69
+ _set_in_payload(repaired, ["subtotal"], float(running_subtotal))
70
+
71
+ # 3. Grand Total Repair
72
+ running_grand = to_decimal(norm.subtotal)
73
+ if norm.tax_total is not None:
74
+ running_grand += to_decimal(norm.tax_total)
75
+ if norm.shipping_amount is not None:
76
+ running_grand += to_decimal(norm.shipping_amount)
77
+ if norm.discount_amount is not None:
78
+ running_grand -= to_decimal(norm.discount_amount)
79
+ running_grand = round_decimal(running_grand, 2, rounding_mode)
80
+
81
+ if norm.grand_total is not None:
82
+ cur_grand = round_decimal(to_decimal(norm.grand_total), 2, rounding_mode)
83
+ if abs(cur_grand - running_grand) > tol:
84
+ changes.append(
85
+ FieldChange(
86
+ field="grand_total",
87
+ old_value=norm.grand_total,
88
+ new_value=float(running_grand),
89
+ reason="Reconciled grand total = subtotal + tax + shipping - discounts",
90
+ confidence=0.99,
91
+ )
92
+ )
93
+ norm.grand_total = float(running_grand)
94
+ _set_in_payload(repaired, ["grand_total", "total", "total_amount", "InvoiceTotal"], float(running_grand))
95
+
96
+ # 4. IBAN Format Sanitization
97
+ if norm.identifiers.iban:
98
+ clean_iban = norm.identifiers.iban.upper().replace(" ", "").replace("-", "")
99
+ if clean_iban != norm.identifiers.iban:
100
+ changes.append(
101
+ FieldChange(
102
+ field="identifiers.iban",
103
+ old_value=norm.identifiers.iban,
104
+ new_value=clean_iban,
105
+ reason="Standardized IBAN formatting",
106
+ confidence=1.0,
107
+ )
108
+ )
109
+ norm.identifiers.iban = clean_iban
110
+ _set_in_payload(repaired, ["iban", "bank_account", "IBAN"], clean_iban)
111
+
112
+ return RepairResult(
113
+ repaired=repaired,
114
+ normalized=norm,
115
+ changes=changes,
116
+ was_repaired=len(changes) > 0,
117
+ )
118
+
119
+
120
+ def _update_in_payload(root: Dict[str, Any], arr_keys: List[str], idx: int, field_keys: List[str], val: Any) -> None:
121
+ for ak in arr_keys:
122
+ if ak in root and isinstance(root[ak], list) and idx < len(root[ak]):
123
+ target = root[ak][idx]
124
+ if isinstance(target, dict):
125
+ for fk in field_keys:
126
+ if fk in target:
127
+ target[fk] = val
128
+ return
129
+ target[field_keys[0]] = val
130
+ return
131
+
132
+
133
+ def _set_in_payload(root: Dict[str, Any], keys: List[str], val: Any) -> None:
134
+ for k in keys:
135
+ if k in root:
136
+ root[k] = val
137
+ return
138
+ root[keys[0]] = val
@@ -0,0 +1,94 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Dict, List, Optional, Any
3
+
4
+
5
+ @dataclass
6
+ class NormalizedLineItem:
7
+ quantity: float
8
+ unit_price: float
9
+ line_total: float
10
+ id: Optional[str] = None
11
+ description: Optional[str] = None
12
+ discount_rate: Optional[float] = None
13
+ discount_amount: Optional[float] = None
14
+ tax_rate: Optional[float] = None
15
+ tax_amount: Optional[float] = None
16
+ raw: Dict[str, Any] = field(default_factory=dict)
17
+
18
+
19
+ @dataclass
20
+ class NormalizedTaxBracket:
21
+ rate: float
22
+ basis: float
23
+ amount: float
24
+
25
+
26
+ @dataclass
27
+ class Identifiers:
28
+ iban: Optional[str] = None
29
+ vat_id: Optional[str] = None
30
+ ein: Optional[str] = None
31
+ abn: Optional[str] = None
32
+
33
+
34
+ @dataclass
35
+ class NormalizedInvoice:
36
+ items: List[NormalizedLineItem]
37
+ tax_brackets: List[NormalizedTaxBracket]
38
+ identifiers: Identifiers
39
+ invoice_id: Optional[str] = None
40
+ currency: Optional[str] = None
41
+ subtotal: Optional[float] = None
42
+ tax_total: Optional[float] = None
43
+ grand_total: Optional[float] = None
44
+ shipping_amount: Optional[float] = None
45
+ discount_amount: Optional[float] = None
46
+ amount_paid: Optional[float] = None
47
+ amount_due: Optional[float] = None
48
+ raw: Dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ @dataclass
52
+ class ValidationError:
53
+ code: str
54
+ field: str
55
+ severity: str
56
+ message: str
57
+ expected: Optional[Any] = None
58
+ actual: Optional[Any] = None
59
+ discrepancy: Optional[float] = None
60
+
61
+
62
+ @dataclass
63
+ class VerificationSummary:
64
+ computed_subtotal: float
65
+ computed_tax_total: float
66
+ computed_grand_total: float
67
+ extracted_grand_total: Optional[float]
68
+ reconciled: bool
69
+
70
+
71
+ @dataclass
72
+ class VerificationReport:
73
+ is_valid: bool
74
+ score: int
75
+ errors: List[ValidationError]
76
+ warnings: List[ValidationError]
77
+ summary: VerificationSummary
78
+
79
+
80
+ @dataclass
81
+ class FieldChange:
82
+ field: str
83
+ old_value: Any
84
+ new_value: Any
85
+ reason: str
86
+ confidence: float
87
+
88
+
89
+ @dataclass
90
+ class RepairResult:
91
+ repaired: Dict[str, Any]
92
+ normalized: NormalizedInvoice
93
+ changes: List[FieldChange]
94
+ was_repaired: bool
@@ -0,0 +1,54 @@
1
+ from invcheck import verify_invoice, repair_invoice, validate_iban, validate_german_vat, validate_abn
2
+
3
+ def test_valid_docling_invoice():
4
+ payload = {
5
+ "invoice": {
6
+ "invoice_number": "INV-1020",
7
+ "items": [
8
+ {"description": "Compute Units", "quantity": 4, "unit_price": 25.00, "line_total": 100.00},
9
+ {"description": "Networking Transfer", "quantity": 1, "unit_price": 5.50, "line_total": 5.50}
10
+ ],
11
+ "subtotal": 105.50,
12
+ "tax": 20.05,
13
+ "total": 125.55,
14
+ "iban": "DE89370400440532013000",
15
+ "vat_id": "DE136695976"
16
+ }
17
+ }
18
+ report = verify_invoice(payload)
19
+ assert report.is_valid is True
20
+ assert report.score == 100
21
+ assert len(report.errors) == 0
22
+
23
+ def test_detects_arithmetic_hallucination():
24
+ faulty = {
25
+ "items": [
26
+ {"name": "Widget", "qty": 2, "price": 10.00, "total": 25.00} # 2 * 10 != 25
27
+ ],
28
+ "subtotal": 20.00,
29
+ "total": 20.00
30
+ }
31
+ report = verify_invoice(faulty)
32
+ assert report.is_valid is False
33
+ assert any(e.code == "LINE_ITEM_TOTAL_MISMATCH" for e in report.errors)
34
+
35
+ def test_checksum_algorithms():
36
+ assert validate_iban("DE89370400440532013000") is True
37
+ assert validate_iban("DE89370400440532013009") is False
38
+ assert validate_german_vat("DE136695976") is True
39
+ assert validate_abn("51824753556") is True
40
+
41
+ def test_repair_heals_drift():
42
+ dirty = {
43
+ "items": [
44
+ {"item": "Service A", "quantity": 2, "unit_price": "$ 14.50", "line_total": "29.00"}
45
+ ],
46
+ "subtotal": "29.00",
47
+ "tax": "5.51",
48
+ "total": "35.00", # Hallucinated total instead of 34.51
49
+ "iban": "DE 89 3704 0044 0532 0130 00"
50
+ }
51
+ res = repair_invoice(dirty)
52
+ assert res.was_repaired is True
53
+ assert res.repaired["total"] == 34.51
54
+ assert res.repaired["iban"] == "DE89370400440532013000"