invcheck 1.0.0__py3-none-any.whl
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.
- invcheck/__init__.py +51 -0
- invcheck/arithmetic.py +188 -0
- invcheck/checksums.py +101 -0
- invcheck/cli.py +63 -0
- invcheck/decimal_ops.py +24 -0
- invcheck/mcp.py +53 -0
- invcheck/normalizer.py +124 -0
- invcheck/py.typed +0 -0
- invcheck/repair.py +138 -0
- invcheck/types.py +94 -0
- invcheck-1.0.0.dist-info/METADATA +62 -0
- invcheck-1.0.0.dist-info/RECORD +14 -0
- invcheck-1.0.0.dist-info/WHEEL +4 -0
- invcheck-1.0.0.dist-info/entry_points.txt +2 -0
invcheck/__init__.py
ADDED
|
@@ -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
|
+
]
|
invcheck/arithmetic.py
ADDED
|
@@ -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
|
+
)
|
invcheck/checksums.py
ADDED
|
@@ -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
|
invcheck/cli.py
ADDED
|
@@ -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()
|
invcheck/decimal_ops.py
ADDED
|
@@ -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)
|
invcheck/mcp.py
ADDED
|
@@ -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}")
|
invcheck/normalizer.py
ADDED
|
@@ -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
|
+
)
|
invcheck/py.typed
ADDED
|
File without changes
|
invcheck/repair.py
ADDED
|
@@ -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
|
invcheck/types.py
ADDED
|
@@ -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,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,14 @@
|
|
|
1
|
+
invcheck/__init__.py,sha256=M8DS6qjugElBMEGydImZfcnclTTcPs2eHOZ2qCMkhio,1315
|
|
2
|
+
invcheck/arithmetic.py,sha256=dyVD-5C_OZxF9WjkNbrwhZq_77LcFcIV5QB8ZjE8eE0,7797
|
|
3
|
+
invcheck/checksums.py,sha256=J-i8WRirGUOP4rum19S4XZmawPiJIeZhAlF9WSgKb0k,3014
|
|
4
|
+
invcheck/cli.py,sha256=K8n1xaUrhA4_xNLdBY1BIndhlF3sgikiq1iIOVjxDvk,2358
|
|
5
|
+
invcheck/decimal_ops.py,sha256=RctLOM9NkcwSFqvwOwPE9CpQz5eWwSNitKIIaLii4Ro,701
|
|
6
|
+
invcheck/mcp.py,sha256=7Hs6jTA4Ww8XMMeVRfdz09sw2eVxu-1oqZUowmDjDAM,1841
|
|
7
|
+
invcheck/normalizer.py,sha256=sAQ4Ig-Z3NHRk0KEx7jJk8gSQ5N_TXQFi01q0JYf5m0,5431
|
|
8
|
+
invcheck/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
invcheck/repair.py,sha256=yj9-94uk2tqznKfqVDpdNPS48a_4W0LCEPZCjXg-bW4,5325
|
|
10
|
+
invcheck/types.py,sha256=zVOUQS-pYOWPBSyNOs2cMp9Hs409utKByPbjuRCVXL0,2128
|
|
11
|
+
invcheck-1.0.0.dist-info/METADATA,sha256=oiU4E2cAfvC2_-BFs78XcE1mmH7Oa9yc63M_yZFzJQo,3235
|
|
12
|
+
invcheck-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
invcheck-1.0.0.dist-info/entry_points.txt,sha256=Oj0wL2C_sTCCiXJbsv-u_Ck4KEg4bGz-BfVQJL4tWr8,47
|
|
14
|
+
invcheck-1.0.0.dist-info/RECORD,,
|