Spaces:
Sleeping
Sleeping
Upload 20 files
Browse files- src/Invoice Validation Functions/.claude/settings.json +28 -0
- src/Invoice Validation Functions/.claude/settings.local.json +11 -0
- src/Invoice Validation Functions/Bank validation.py +449 -0
- src/Invoice Validation Functions/CLAUDE.md.txt +59 -0
- src/Invoice Validation Functions/Tax - ID validation.py +564 -0
- src/Invoice Validation Functions/Vendor Validation.py +387 -0
- src/Invoice Validation Functions/__pycache__/Bank validation.cpython-311.pyc +0 -0
- src/Invoice Validation Functions/__pycache__/Tax - ID validation.cpython-311.pyc +0 -0
- src/Invoice Validation Functions/__pycache__/Vendor Validation.cpython-311.pyc +0 -0
- src/Invoice Validation Functions/__pycache__/completeness.cpython-311.pyc +0 -0
- src/Invoice Validation Functions/__pycache__/date_sanity.cpython-311.pyc +0 -0
- src/Invoice Validation Functions/__pycache__/orchestrator.cpython-311.pyc +0 -0
- src/Invoice Validation Functions/__pycache__/payment terms valdiation.cpython-311.pyc +0 -0
- src/Invoice Validation Functions/__pycache__/tax validation.cpython-311.pyc +0 -0
- src/Invoice Validation Functions/completeness.py +171 -0
- src/Invoice Validation Functions/date_sanity.py +221 -0
- src/Invoice Validation Functions/orchestrator.py +282 -0
- src/Invoice Validation Functions/payment terms valdiation.py +253 -0
- src/Invoice Validation Functions/platform_configs.json +67 -0
- src/Invoice Validation Functions/tax validation.py +563 -0
src/Invoice Validation Functions/.claude/settings.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"permissions": {
|
| 3 |
+
"allow": [
|
| 4 |
+
"Bash(python -c ' *)",
|
| 5 |
+
"Read(//c/Users/bhuva/Downloads/**)",
|
| 6 |
+
"Read(//c/Users/bhuva/**)",
|
| 7 |
+
"Bash(python -c \"import glob; print\\('\\\\n'.join\\(glob.glob\\(r'c:\\\\Users\\\\bhuva\\\\Downloads\\\\**\\\\*.xlsx', recursive=True\\)\\)\\)\")",
|
| 8 |
+
"Bash(python -c \"import glob; files = glob.glob\\(r'c:\\\\Users\\\\bhuva\\\\Downloads\\\\*master*.xlsx'\\); print\\('\\\\n'.join\\(files\\)\\)\")",
|
| 9 |
+
"Bash(python \"C:/Users/bhuva/AppData/Local/Temp/claude/c--Users-bhuva-Downloads-Invoice-Validation-Functions/52024fb1-90e8-497b-b1a9-726c0979fbcb/scratchpad/fix2.py\")",
|
| 10 |
+
"PowerShell(python \"C:\\\\Users\\\\bhuva\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-bhuva-Downloads-Invoice-Validation-Functions\\\\52024fb1-90e8-497b-b1a9-726c0979fbcb\\\\scratchpad\\\\audit_coverage.py\" 2>&1)",
|
| 11 |
+
"PowerShell(python \"C:\\\\Users\\\\bhuva\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-bhuva-Downloads-Invoice-Validation-Functions\\\\52024fb1-90e8-497b-b1a9-726c0979fbcb\\\\scratchpad\\\\verify_tc_flags.py\" 2>&1)",
|
| 12 |
+
"Bash(python \"C:\\\\Users\\\\bhuva\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-bhuva-Downloads-Invoice-Validation-Functions\\\\52024fb1-90e8-497b-b1a9-726c0979fbcb\\\\scratchpad\\\\verify_tc_flags.py\")",
|
| 13 |
+
"Bash(python -c \"import json; data=[json.loads\\(l\\) for l in open\\(r'C:/Users/bhuva/Downloads/test_invoices_comprehensive.jsonl'\\)]; [print\\(i+1, d.get\\('invoice_number'\\)\\) for i,d in enumerate\\(data\\)]\")",
|
| 14 |
+
"Bash(python -c \"import json; data=[json.loads\\(l\\) for l in open\\(r'C:/Users/bhuva/Downloads/test_invoices_comprehensive.jsonl'\\)]; print\\(list\\(data[7].keys\\(\\)\\)[:20]\\); print\\(json.dumps\\(data[7], indent=2, default=str\\)[:1000]\\)\")",
|
| 15 |
+
"Bash(python \"C:/Users/bhuva/AppData/Local/Temp/claude/c--Users-bhuva-Downloads-Invoice-Validation-Functions/52024fb1-90e8-497b-b1a9-726c0979fbcb/scratchpad/verify_tc_flags.py\")",
|
| 16 |
+
"Bash(powershell -Command \"Get-Content 'C:\\\\Users\\\\bhuva\\\\.claude\\\\projects\\\\c--Users-bhuva-Downloads-Invoice-Validation-Functions\\\\52024fb1-90e8-497b-b1a9-726c0979fbcb.jsonl' | Select-Object -Last 300\")",
|
| 17 |
+
"Read(//c/Users/bhuva/.claude/projects/c--Users-bhuva-Downloads-Invoice-Validation-Functions/**)",
|
| 18 |
+
"Bash(python3 -c ' *)",
|
| 19 |
+
"Bash(find /c/Users/bhuva -iname \"*comprehensive*\" 2>/dev/null | head -50)",
|
| 20 |
+
"Bash(grep -n \"TC019\\\\|TC-019\" \"/c/Users/bhuva/Downloads/test_invoices_comprehensive.jsonl\")",
|
| 21 |
+
"Bash(pip show *)"
|
| 22 |
+
],
|
| 23 |
+
"additionalDirectories": [
|
| 24 |
+
"c:\\Users\\bhuva\\Downloads",
|
| 25 |
+
"C:\\Users\\bhuva\\Downloads"
|
| 26 |
+
]
|
| 27 |
+
}
|
| 28 |
+
}
|
src/Invoice Validation Functions/.claude/settings.local.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"permissions": {
|
| 3 |
+
"allow": [
|
| 4 |
+
"Bash(rm \"c:/Users/bhuva/Downloads/Matching Functions/pyrightconfig.json\")",
|
| 5 |
+
"PowerShell(cd \"c:\\\\Users\\\\bhuva\\\\Downloads\\\\Matching Functions\"; $env:PYTHONIOENCODING=\"utf-8\"; python edge_case_tests.py 2>&1)"
|
| 6 |
+
],
|
| 7 |
+
"additionalDirectories": [
|
| 8 |
+
"c:\\Users\\bhuva\\Downloads\\Matching Functions"
|
| 9 |
+
]
|
| 10 |
+
}
|
| 11 |
+
}
|
src/Invoice Validation Functions/Bank validation.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from datetime import date, datetime
|
| 3 |
+
from hashlib import sha256
|
| 4 |
+
from unicodedata import normalize
|
| 5 |
+
from decimal import Decimal
|
| 6 |
+
|
| 7 |
+
from rapidfuzz import fuzz
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def run_bank_validation(
|
| 11 |
+
*,
|
| 12 |
+
document,
|
| 13 |
+
extracted_fields,
|
| 14 |
+
vendor_bank_accounts,
|
| 15 |
+
resolved_vendor_id,
|
| 16 |
+
platform_configs=None,
|
| 17 |
+
name_match_fn=None,
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
Bank validation logic based on Cat 2 section 7.4.
|
| 21 |
+
|
| 22 |
+
Expected document fields:
|
| 23 |
+
document.id
|
| 24 |
+
document.tenant_id
|
| 25 |
+
|
| 26 |
+
Expected extracted_fields fields:
|
| 27 |
+
invoice_date
|
| 28 |
+
currency
|
| 29 |
+
bank_iban
|
| 30 |
+
bank_acc_no
|
| 31 |
+
bank_swift
|
| 32 |
+
bank_acc_name
|
| 33 |
+
|
| 34 |
+
Expected vendor_bank_accounts row fields:
|
| 35 |
+
tenant_id
|
| 36 |
+
vendor_id
|
| 37 |
+
iban_canonical
|
| 38 |
+
acc_no_canonical
|
| 39 |
+
swift_bic
|
| 40 |
+
bank_acc_name
|
| 41 |
+
currency_code
|
| 42 |
+
bank_country
|
| 43 |
+
is_primary
|
| 44 |
+
effective_from
|
| 45 |
+
effective_to
|
| 46 |
+
deleted_at
|
| 47 |
+
|
| 48 |
+
name_match_fn:
|
| 49 |
+
Optional function for bank account name comparison.
|
| 50 |
+
Should return something like:
|
| 51 |
+
{"decision": "matched" | "suggestion" | "unmatched"}
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
platform_configs = platform_configs or {}
|
| 55 |
+
|
| 56 |
+
iban_checksum_required = platform_configs.get(
|
| 57 |
+
"validation.iban_checksum_required",
|
| 58 |
+
True,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
iban_accno_exempt_countries = set(
|
| 62 |
+
platform_configs.get(
|
| 63 |
+
"validation.iban_accno_substring_exempt_countries",
|
| 64 |
+
["LC", "MT"],
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
high_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_high_threshold", "0.90")))
|
| 69 |
+
medium_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_medium_threshold", "0.75")))
|
| 70 |
+
suggestion_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_suggestion_threshold", "0.60")))
|
| 71 |
+
|
| 72 |
+
def hash_prefix(value, length=8):
|
| 73 |
+
if not value:
|
| 74 |
+
return None
|
| 75 |
+
return sha256(str(value).encode("utf-8")).hexdigest()[:length]
|
| 76 |
+
|
| 77 |
+
def canonicalize_text(value):
|
| 78 |
+
if value is None:
|
| 79 |
+
return None
|
| 80 |
+
value = normalize("NFKC", str(value))
|
| 81 |
+
value = value.strip().upper()
|
| 82 |
+
return value or None
|
| 83 |
+
|
| 84 |
+
def canonicalize_iban(value):
|
| 85 |
+
if value is None:
|
| 86 |
+
return None
|
| 87 |
+
value = normalize("NFKC", str(value))
|
| 88 |
+
value = re.sub(r"[\s\-\.,/\\:;_]+", "", value)
|
| 89 |
+
value = value.upper()
|
| 90 |
+
return value or None
|
| 91 |
+
|
| 92 |
+
def canonicalize_acc_no(value):
|
| 93 |
+
if value is None:
|
| 94 |
+
return None
|
| 95 |
+
value = normalize("NFKC", str(value))
|
| 96 |
+
value = re.sub(r"[\s\-\.,/\\:;_]+", "", value)
|
| 97 |
+
value = value.upper()
|
| 98 |
+
return value or None
|
| 99 |
+
|
| 100 |
+
def canonicalize_swift(value):
|
| 101 |
+
if value is None:
|
| 102 |
+
return None
|
| 103 |
+
value = normalize("NFKC", str(value))
|
| 104 |
+
value = re.sub(r"\s+", "", value)
|
| 105 |
+
value = value.upper()
|
| 106 |
+
return value or None
|
| 107 |
+
|
| 108 |
+
def iban_country(iban):
|
| 109 |
+
if not iban or len(iban) < 2:
|
| 110 |
+
return None
|
| 111 |
+
return iban[:2]
|
| 112 |
+
|
| 113 |
+
def iban_checksum_mod97_valid(iban):
|
| 114 |
+
"""
|
| 115 |
+
Standard IBAN mod-97 validation.
|
| 116 |
+
|
| 117 |
+
Move first 4 chars to end.
|
| 118 |
+
Replace letters A=10 ... Z=35.
|
| 119 |
+
Result mod 97 must equal 1.
|
| 120 |
+
"""
|
| 121 |
+
if not iban or len(iban) < 4:
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
rearranged = iban[4:] + iban[:4]
|
| 125 |
+
numeric = ""
|
| 126 |
+
|
| 127 |
+
for char in rearranged:
|
| 128 |
+
if char.isdigit():
|
| 129 |
+
numeric += char
|
| 130 |
+
elif "A" <= char <= "Z":
|
| 131 |
+
numeric += str(ord(char) - ord("A") + 10)
|
| 132 |
+
else:
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
remainder = 0
|
| 136 |
+
for digit in numeric:
|
| 137 |
+
remainder = (remainder * 10 + int(digit)) % 97
|
| 138 |
+
|
| 139 |
+
return remainder == 1
|
| 140 |
+
|
| 141 |
+
def iban_acc_no_consistent(iban, acc_no):
|
| 142 |
+
"""
|
| 143 |
+
Basic consistency check:
|
| 144 |
+
remove country + check digits from IBAN,
|
| 145 |
+
then check whether account number appears inside BBAN.
|
| 146 |
+
|
| 147 |
+
Leading zeros are ignored for comparison.
|
| 148 |
+
"""
|
| 149 |
+
if not iban or not acc_no:
|
| 150 |
+
return True
|
| 151 |
+
|
| 152 |
+
country = iban_country(iban)
|
| 153 |
+
|
| 154 |
+
if country in iban_accno_exempt_countries:
|
| 155 |
+
return True
|
| 156 |
+
|
| 157 |
+
bban = iban[4:]
|
| 158 |
+
stripped_acc_no = acc_no.lstrip("0") or acc_no
|
| 159 |
+
|
| 160 |
+
return stripped_acc_no in bban
|
| 161 |
+
|
| 162 |
+
def is_effective(row, invoice_date):
|
| 163 |
+
if getattr(row, "deleted_at", None):
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
if invoice_date is None:
|
| 167 |
+
return False
|
| 168 |
+
|
| 169 |
+
effective_from = getattr(row, "effective_from", None)
|
| 170 |
+
effective_to = getattr(row, "effective_to", None)
|
| 171 |
+
|
| 172 |
+
if effective_from is not None and invoice_date < effective_from:
|
| 173 |
+
return False
|
| 174 |
+
|
| 175 |
+
if effective_to and invoice_date >= effective_to:
|
| 176 |
+
return False
|
| 177 |
+
|
| 178 |
+
return True
|
| 179 |
+
|
| 180 |
+
def default_name_match(invoice_name, master_name):
|
| 181 |
+
if not invoice_name or not master_name:
|
| 182 |
+
return {"decision": "unmatched"}
|
| 183 |
+
|
| 184 |
+
score = Decimal(str(fuzz.WRatio(invoice_name, master_name) / 100)).quantize(
|
| 185 |
+
Decimal("0.0001")
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
if score >= high_threshold:
|
| 189 |
+
return {"decision": "matched", "score_tier": "high"}
|
| 190 |
+
if score >= medium_threshold:
|
| 191 |
+
return {"decision": "matched", "score_tier": "medium"}
|
| 192 |
+
if score >= suggestion_threshold:
|
| 193 |
+
return {"decision": "suggestion", "score_tier": "low"}
|
| 194 |
+
return {"decision": "unmatched", "score_tier": "low"}
|
| 195 |
+
|
| 196 |
+
def bank_account_name_matches(invoice_name, master_name):
|
| 197 |
+
matcher = name_match_fn or default_name_match
|
| 198 |
+
|
| 199 |
+
result = matcher(
|
| 200 |
+
query_name=invoice_name,
|
| 201 |
+
candidate_pool=[master_name],
|
| 202 |
+
context={"purpose": "bank_acc_name_check"},
|
| 203 |
+
) if name_match_fn else matcher(invoice_name, master_name)
|
| 204 |
+
|
| 205 |
+
return result.get("decision") == "matched"
|
| 206 |
+
|
| 207 |
+
def add_flag(code, *, field=None, weight=None, match_result="failed", invoice_value=None,
|
| 208 |
+
master_value=None, currency_invoice=None, currency_master=None,
|
| 209 |
+
primary_or_non_primary=None):
|
| 210 |
+
flag = {
|
| 211 |
+
"code": code,
|
| 212 |
+
"field": field,
|
| 213 |
+
"severity": "advisory",
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
if weight is not None:
|
| 217 |
+
flag["weight"] = weight
|
| 218 |
+
|
| 219 |
+
risk_flags.append(flag)
|
| 220 |
+
|
| 221 |
+
audit_entries.append(
|
| 222 |
+
{
|
| 223 |
+
"field": field,
|
| 224 |
+
"match_result": match_result,
|
| 225 |
+
"invoice_hash_prefix": hash_prefix(invoice_value),
|
| 226 |
+
"master_hash_prefix": hash_prefix(master_value),
|
| 227 |
+
"currency_invoice": currency_invoice,
|
| 228 |
+
"currency_master": currency_master,
|
| 229 |
+
"primary_or_non_primary": primary_or_non_primary,
|
| 230 |
+
}
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
risk_flags = []
|
| 234 |
+
advisory_flags = []
|
| 235 |
+
skipped_steps = []
|
| 236 |
+
audit_entries = []
|
| 237 |
+
|
| 238 |
+
if resolved_vendor_id is None:
|
| 239 |
+
skipped_steps.append(
|
| 240 |
+
{
|
| 241 |
+
"step": "bank_validation",
|
| 242 |
+
"skip_reason": "vendor_unresolved",
|
| 243 |
+
}
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
"risk_flags": risk_flags,
|
| 248 |
+
"advisory_flags": advisory_flags,
|
| 249 |
+
"skipped_steps": skipped_steps,
|
| 250 |
+
"audit_entries": audit_entries,
|
| 251 |
+
"match_result": "skipped",
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
invoice_date = getattr(extracted_fields, "invoice_date", None)
|
| 255 |
+
|
| 256 |
+
if isinstance(invoice_date, datetime):
|
| 257 |
+
invoice_date = invoice_date.date()
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
invoice_iban = canonicalize_iban(getattr(extracted_fields, "bank_iban", None))
|
| 261 |
+
invoice_acc_no = canonicalize_acc_no(getattr(extracted_fields, "bank_acc_no", None))
|
| 262 |
+
invoice_swift = canonicalize_swift(getattr(extracted_fields, "bank_swift", None))
|
| 263 |
+
invoice_bank_acc_name = getattr(extracted_fields, "bank_acc_name", None)
|
| 264 |
+
invoice_currency = canonicalize_text(getattr(extracted_fields, "currency", None))
|
| 265 |
+
|
| 266 |
+
# 1. Invoice-side pre-format checks.
|
| 267 |
+
if invoice_iban and iban_checksum_required:
|
| 268 |
+
if not iban_checksum_mod97_valid(invoice_iban):
|
| 269 |
+
add_flag(
|
| 270 |
+
"bank_iban_invalid_checksum",
|
| 271 |
+
field="bank_iban",
|
| 272 |
+
weight=Decimal("0.35"),
|
| 273 |
+
invoice_value=invoice_iban,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
if invoice_iban and invoice_acc_no:
|
| 277 |
+
if not iban_acc_no_consistent(invoice_iban, invoice_acc_no):
|
| 278 |
+
add_flag(
|
| 279 |
+
"bank_iban_accno_inconsistent",
|
| 280 |
+
field="bank_iban/bank_acc_no",
|
| 281 |
+
weight=Decimal("0.35"),
|
| 282 |
+
invoice_value=f"{invoice_iban}|{invoice_acc_no}",
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# 2. Build same-tenant, same-vendor, active candidate pool.
|
| 286 |
+
# all_vendor_rows: non-deleted rows for this vendor, no date filtering.
|
| 287 |
+
# Used for new_bank_details_no_master — distinguishes "no rows at all" from
|
| 288 |
+
# "rows exist but none are currently effective".
|
| 289 |
+
all_vendor_rows = [
|
| 290 |
+
row
|
| 291 |
+
for row in vendor_bank_accounts
|
| 292 |
+
if getattr(row, "tenant_id", None) == document.tenant_id
|
| 293 |
+
and getattr(row, "vendor_id", None) == resolved_vendor_id
|
| 294 |
+
and not getattr(row, "deleted_at", None)
|
| 295 |
+
]
|
| 296 |
+
|
| 297 |
+
candidate_pool = [
|
| 298 |
+
row for row in all_vendor_rows
|
| 299 |
+
if is_effective(row, invoice_date)
|
| 300 |
+
]
|
| 301 |
+
|
| 302 |
+
candidate_pool.sort(
|
| 303 |
+
key=lambda row: (
|
| 304 |
+
bool(getattr(row, "is_primary", False)),
|
| 305 |
+
getattr(row, "effective_from", None) or date.max,
|
| 306 |
+
),
|
| 307 |
+
reverse=True,
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
primary_rows = [
|
| 311 |
+
row for row in candidate_pool
|
| 312 |
+
if bool(getattr(row, "is_primary", False))
|
| 313 |
+
]
|
| 314 |
+
|
| 315 |
+
non_primary_rows = [
|
| 316 |
+
row for row in candidate_pool
|
| 317 |
+
if not bool(getattr(row, "is_primary", False))
|
| 318 |
+
]
|
| 319 |
+
|
| 320 |
+
primary = primary_rows[0] if primary_rows else None
|
| 321 |
+
|
| 322 |
+
invoice_has_any_bank_detail = any(
|
| 323 |
+
[
|
| 324 |
+
invoice_iban,
|
| 325 |
+
invoice_acc_no,
|
| 326 |
+
invoice_swift,
|
| 327 |
+
invoice_bank_acc_name,
|
| 328 |
+
]
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
if invoice_has_any_bank_detail and not all_vendor_rows:
|
| 332 |
+
add_flag(
|
| 333 |
+
"new_bank_details_no_master",
|
| 334 |
+
field="bank_details",
|
| 335 |
+
invoice_value="bank_details_present",
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
return {
|
| 339 |
+
"risk_flags": risk_flags,
|
| 340 |
+
"advisory_flags": advisory_flags,
|
| 341 |
+
"skipped_steps": skipped_steps,
|
| 342 |
+
"audit_entries": audit_entries,
|
| 343 |
+
"match_result": "no_master_bank_details",
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
# 3. Compare against primary account.
|
| 347 |
+
if primary:
|
| 348 |
+
primary_match = True
|
| 349 |
+
|
| 350 |
+
master_iban = getattr(primary, "iban_canonical", None)
|
| 351 |
+
master_acc_no = getattr(primary, "acc_no_canonical", None)
|
| 352 |
+
master_swift = canonicalize_swift(getattr(primary, "swift_bic", None))
|
| 353 |
+
master_bank_acc_name = getattr(primary, "bank_acc_name", None)
|
| 354 |
+
master_currency = canonicalize_text(getattr(primary, "currency_code", None))
|
| 355 |
+
|
| 356 |
+
if invoice_iban:
|
| 357 |
+
if not master_iban:
|
| 358 |
+
primary_match = False
|
| 359 |
+
add_flag(
|
| 360 |
+
"new_bank_field_type_no_master",
|
| 361 |
+
field="bank_iban",
|
| 362 |
+
invoice_value=invoice_iban,
|
| 363 |
+
primary_or_non_primary="primary",
|
| 364 |
+
)
|
| 365 |
+
elif invoice_iban != master_iban:
|
| 366 |
+
primary_match = False
|
| 367 |
+
add_flag(
|
| 368 |
+
"bank_iban_mismatch",
|
| 369 |
+
field="bank_iban",
|
| 370 |
+
invoice_value=invoice_iban,
|
| 371 |
+
master_value=master_iban,
|
| 372 |
+
primary_or_non_primary="primary",
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
if invoice_acc_no and master_acc_no and invoice_acc_no != master_acc_no:
|
| 376 |
+
primary_match = False
|
| 377 |
+
add_flag(
|
| 378 |
+
"bank_acc_no_mismatch",
|
| 379 |
+
field="bank_acc_no",
|
| 380 |
+
invoice_value=invoice_acc_no,
|
| 381 |
+
master_value=master_acc_no,
|
| 382 |
+
primary_or_non_primary="primary",
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
if invoice_swift and master_swift and invoice_swift != master_swift:
|
| 386 |
+
primary_match = False
|
| 387 |
+
add_flag(
|
| 388 |
+
"bank_swift_mismatch",
|
| 389 |
+
field="bank_swift",
|
| 390 |
+
invoice_value=invoice_swift,
|
| 391 |
+
master_value=master_swift,
|
| 392 |
+
primary_or_non_primary="primary",
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
if invoice_bank_acc_name and master_bank_acc_name:
|
| 396 |
+
if not bank_account_name_matches(invoice_bank_acc_name, master_bank_acc_name):
|
| 397 |
+
primary_match = False
|
| 398 |
+
add_flag(
|
| 399 |
+
"bank_acc_name_mismatch",
|
| 400 |
+
field="bank_acc_name",
|
| 401 |
+
weight=Decimal("0.30"),
|
| 402 |
+
invoice_value=invoice_bank_acc_name,
|
| 403 |
+
master_value=master_bank_acc_name,
|
| 404 |
+
primary_or_non_primary="primary",
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
if invoice_currency and master_currency and invoice_currency != master_currency:
|
| 408 |
+
primary_match = False
|
| 409 |
+
add_flag(
|
| 410 |
+
"bank_currency_mismatch",
|
| 411 |
+
field="currency",
|
| 412 |
+
weight=Decimal("0.20"),
|
| 413 |
+
currency_invoice=invoice_currency,
|
| 414 |
+
currency_master=master_currency,
|
| 415 |
+
primary_or_non_primary="primary",
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
match_result = "primary_match" if primary_match else "primary_mismatch"
|
| 419 |
+
|
| 420 |
+
else:
|
| 421 |
+
match_result = "no_primary_bank_account"
|
| 422 |
+
|
| 423 |
+
secondary_match = None
|
| 424 |
+
for row in non_primary_rows:
|
| 425 |
+
if invoice_iban and getattr(row, "iban_canonical", None) == invoice_iban:
|
| 426 |
+
secondary_match = row
|
| 427 |
+
break
|
| 428 |
+
|
| 429 |
+
if secondary_match:
|
| 430 |
+
add_flag(
|
| 431 |
+
"bank_non_primary_match",
|
| 432 |
+
field="bank_iban",
|
| 433 |
+
weight=Decimal("0.25"),
|
| 434 |
+
match_result="matched_non_primary",
|
| 435 |
+
invoice_value=invoice_iban,
|
| 436 |
+
master_value=getattr(secondary_match, "iban_canonical", None),
|
| 437 |
+
primary_or_non_primary="non_primary",
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
return {
|
| 441 |
+
"risk_flags": risk_flags,
|
| 442 |
+
"advisory_flags": advisory_flags,
|
| 443 |
+
"skipped_steps": skipped_steps,
|
| 444 |
+
"audit_entries": audit_entries,
|
| 445 |
+
"match_result": match_result,
|
| 446 |
+
"candidate_count": len(candidate_pool),
|
| 447 |
+
"primary_candidate_count": len(primary_rows),
|
| 448 |
+
"non_primary_candidate_count": len(non_primary_rows),
|
| 449 |
+
}
|
src/Invoice Validation Functions/CLAUDE.md.txt
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Invoice Validation Project
|
| 2 |
+
|
| 3 |
+
## Architecture
|
| 4 |
+
|
| 5 |
+
This project implements an invoice validation engine.
|
| 6 |
+
|
| 7 |
+
Validation modules are implemented separately and must remain separate.
|
| 8 |
+
|
| 9 |
+
Current validation modules:
|
| 10 |
+
|
| 11 |
+
1. Vendor Validation
|
| 12 |
+
2. Tax ID Validation
|
| 13 |
+
3. Bank Validation
|
| 14 |
+
4. Date Sanity Check
|
| 15 |
+
5. Payment Terms Validation
|
| 16 |
+
6. Tax Validation
|
| 17 |
+
7. Completeness Check
|
| 18 |
+
|
| 19 |
+
## Rules
|
| 20 |
+
|
| 21 |
+
* Do not merge validations into one large function.
|
| 22 |
+
* Keep business logic inside each validation module.
|
| 23 |
+
* The orchestration layer should only coordinate execution.
|
| 24 |
+
* Vendor Validation runs first.
|
| 25 |
+
* Any validation module that depends on vendor master data must receive resolved_vendor_id from Vendor Validation.
|
| 26 |
+
* Pass resolved_vendor_id to all downstream validations that require vendor-specific lookups or checks.
|
| 27 |
+
* All validation configuration is stored in platform_configs.json.
|
| 28 |
+
* Validation functions receive platform_configs as input.
|
| 29 |
+
* Do not hardcode operational thresholds.
|
| 30 |
+
* Use the SDD as the source of truth.
|
| 31 |
+
* Tenant isolation must always be respected.
|
| 32 |
+
|
| 33 |
+
## Expected Outcome
|
| 34 |
+
|
| 35 |
+
After all validation modules are verified against the SDD:
|
| 36 |
+
|
| 37 |
+
* Create a lightweight orchestrator.
|
| 38 |
+
* The orchestrator should load platform_configs.json once.
|
| 39 |
+
* The orchestrator should call validators in the correct order.
|
| 40 |
+
* The orchestrator should pass outputs from one validator to downstream validators where required.
|
| 41 |
+
* The orchestrator should aggregate risk flags and advisory flags.
|
| 42 |
+
* No business logic should be implemented in the orchestrator.
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
## Matching Project Rules
|
| 46 |
+
|
| 47 |
+
- Matching runs after validation.
|
| 48 |
+
- Do not run all match types for every invoice.
|
| 49 |
+
- First decide the match type using a routing step.
|
| 50 |
+
- Routing should decide:
|
| 51 |
+
- 2-way match
|
| 52 |
+
- 3-way match
|
| 53 |
+
- contract match
|
| 54 |
+
- no match
|
| 55 |
+
- Keep match routing separate from match logic.
|
| 56 |
+
- Keep each match type in its own file.
|
| 57 |
+
- The matching orchestrator should only connect routing and match modules.
|
| 58 |
+
- Do not put matching business logic inside the orchestrator.
|
| 59 |
+
- Final output should include validation result + matching result.
|
src/Invoice Validation Functions/Tax - ID validation.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from datetime import datetime, date, timezone
|
| 3 |
+
from hashlib import sha256
|
| 4 |
+
from unicodedata import normalize
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _h_gb_vat_mod97(value, **_kw):
|
| 8 |
+
# UK VAT mod-97. Tries Method A then Method B (new-style) per HMRC spec.
|
| 9 |
+
# Value: 9 or 12 digits, GB prefix already stripped.
|
| 10 |
+
digits = re.sub(r"\D", "", value)
|
| 11 |
+
if len(digits) not in (9, 12):
|
| 12 |
+
return False
|
| 13 |
+
d = [int(c) for c in digits[:9]]
|
| 14 |
+
weights = [8, 7, 6, 5, 4, 3, 2]
|
| 15 |
+
s = sum(weights[i] * d[i] for i in range(7))
|
| 16 |
+
check = d[7] * 10 + d[8]
|
| 17 |
+
return (s - check) % 97 == 0 or (s + 55 - check) % 97 == 0
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _h_de_vat_mod11(value, **_kw):
|
| 21 |
+
# German VAT recursive mod-11 check digit.
|
| 22 |
+
# Value: 9 digits, DE prefix already stripped.
|
| 23 |
+
digits = re.sub(r"\D", "", value)
|
| 24 |
+
if len(digits) != 9:
|
| 25 |
+
return False
|
| 26 |
+
product = 10
|
| 27 |
+
for c in digits[:8]:
|
| 28 |
+
s = (int(c) + product) % 10
|
| 29 |
+
if s == 0:
|
| 30 |
+
s = 10
|
| 31 |
+
product = (2 * s) % 11
|
| 32 |
+
check = 11 - product
|
| 33 |
+
if check == 10:
|
| 34 |
+
check = 0
|
| 35 |
+
return int(digits[8]) == check
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _h_fr_vat_mod97(value, **_kw):
|
| 39 |
+
# French TVA key check.
|
| 40 |
+
# Value: 11 chars (FR prefix stripped): 2-char key + 9-digit SIREN.
|
| 41 |
+
# Numeric keys use the standard formula; alphanumeric keys use base-36 encoding.
|
| 42 |
+
if len(value) != 11:
|
| 43 |
+
return False
|
| 44 |
+
siren_str = value[2:]
|
| 45 |
+
if not siren_str.isdigit():
|
| 46 |
+
return False
|
| 47 |
+
siren = int(siren_str)
|
| 48 |
+
key = value[:2]
|
| 49 |
+
if key.isdigit():
|
| 50 |
+
return int(key) == (12 + 3 * (siren % 97)) % 97
|
| 51 |
+
def _char_val(c):
|
| 52 |
+
return int(c) if c.isdigit() else ord(c.upper()) - 55
|
| 53 |
+
k = _char_val(key[0]) * 36 + _char_val(key[1])
|
| 54 |
+
return k == (24 + 25 * (siren % 97)) % 97
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _h_it_vat_luhn(value, **_kw):
|
| 58 |
+
# Italian Partita IVA Luhn-style check digit.
|
| 59 |
+
# Value: 11 digits, IT prefix already stripped.
|
| 60 |
+
digits = re.sub(r"\D", "", value)
|
| 61 |
+
if len(digits) != 11:
|
| 62 |
+
return False
|
| 63 |
+
odd_sum = sum(int(digits[i]) for i in range(0, 10, 2))
|
| 64 |
+
even_sum = 0
|
| 65 |
+
for i in range(1, 10, 2):
|
| 66 |
+
d = int(digits[i]) * 2
|
| 67 |
+
if d > 9:
|
| 68 |
+
d -= 9
|
| 69 |
+
even_sum += d
|
| 70 |
+
total = odd_sum + even_sum
|
| 71 |
+
return int(digits[10]) == (10 - total % 10) % 10
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _h_es_nif(value, **_kw):
|
| 75 |
+
# Spanish NIF/CIF check character.
|
| 76 |
+
# Value: 9 chars (ES prefix stripped): [A-Z0-9][0-9]{7}[A-Z0-9].
|
| 77 |
+
if len(value) != 9:
|
| 78 |
+
return False
|
| 79 |
+
middle = value[1:8]
|
| 80 |
+
if not middle.isdigit():
|
| 81 |
+
return False
|
| 82 |
+
total = 0
|
| 83 |
+
for i, c in enumerate(middle):
|
| 84 |
+
d = int(c)
|
| 85 |
+
if i % 2 == 0:
|
| 86 |
+
d *= 2
|
| 87 |
+
if d > 9:
|
| 88 |
+
d -= 9
|
| 89 |
+
total += d
|
| 90 |
+
control = (10 - total % 10) % 10
|
| 91 |
+
check = value[8]
|
| 92 |
+
if check.isdigit():
|
| 93 |
+
return int(check) == control
|
| 94 |
+
return check.upper() == "JABCDEFGHI"[control]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _h_gstin_crc(value, **_kw):
|
| 98 |
+
# Indian GSTIN Luhn-mod36 check digit (official GSTN algorithm).
|
| 99 |
+
# Value: 15-char GSTIN (no prefix removed — GSTIN has no country prefix in its pattern).
|
| 100 |
+
if len(value) != 15:
|
| 101 |
+
return False
|
| 102 |
+
charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
| 103 |
+
total = 0
|
| 104 |
+
factor = 2
|
| 105 |
+
for c in value[:14]:
|
| 106 |
+
if c not in charset:
|
| 107 |
+
return False
|
| 108 |
+
pos = charset.index(c)
|
| 109 |
+
product = factor * pos
|
| 110 |
+
total += product // 36 + product % 36
|
| 111 |
+
factor = 3 - factor
|
| 112 |
+
check_pos = (36 - total % 36) % 36
|
| 113 |
+
return value[14] == charset[check_pos]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _h_abn_mod89(value, **_kw):
|
| 117 |
+
# Australian Business Number mod-89 weighted check.
|
| 118 |
+
# Value: 11 digits (no prefix removed).
|
| 119 |
+
digits = re.sub(r"\D", "", value)
|
| 120 |
+
if len(digits) != 11:
|
| 121 |
+
return False
|
| 122 |
+
weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
|
| 123 |
+
d = [int(c) for c in digits]
|
| 124 |
+
d[0] -= 1
|
| 125 |
+
return sum(d[i] * weights[i] for i in range(11)) % 89 == 0
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _h_bn_luhn(value, **_kw):
|
| 129 |
+
# Canadian Business Number Luhn check on the 9-digit registration number.
|
| 130 |
+
# The optional 6-char program account suffix (if present) is ignored.
|
| 131 |
+
nine = value[:9]
|
| 132 |
+
if len(nine) != 9 or not nine.isdigit():
|
| 133 |
+
return False
|
| 134 |
+
total = 0
|
| 135 |
+
for i in range(9):
|
| 136 |
+
d = int(nine[i])
|
| 137 |
+
if i % 2 == 1:
|
| 138 |
+
d *= 2
|
| 139 |
+
if d > 9:
|
| 140 |
+
d -= 9
|
| 141 |
+
total += d
|
| 142 |
+
return total % 10 == 0
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _h_sg_nric_fin(value, **_kw):
|
| 146 |
+
# Singapore NRIC/FIN check character.
|
| 147 |
+
# Value: 9 chars — [STFG][0-9]{7}[A-Z] (no prefix removed).
|
| 148 |
+
value = value.upper()
|
| 149 |
+
if len(value) != 9 or value[0] not in "STFG" or not value[1:8].isdigit():
|
| 150 |
+
return False
|
| 151 |
+
weights = [2, 7, 6, 5, 4, 3, 2]
|
| 152 |
+
total = sum(int(value[i + 1]) * weights[i] for i in range(7))
|
| 153 |
+
if value[0] in "TG":
|
| 154 |
+
total += 4
|
| 155 |
+
check_chars = "JZIHGFEDCBA" if value[0] in "ST" else "XWUTRQPNMLK"
|
| 156 |
+
return value[8] == check_chars[total % 11]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
_BUILTIN_CHECKSUM_HANDLERS = {
|
| 160 |
+
"gb_vat_mod97": _h_gb_vat_mod97,
|
| 161 |
+
"de_vat_mod11": _h_de_vat_mod11,
|
| 162 |
+
"fr_vat_mod97": _h_fr_vat_mod97,
|
| 163 |
+
"it_vat_luhn": _h_it_vat_luhn,
|
| 164 |
+
"es_nif": _h_es_nif,
|
| 165 |
+
"gstin_crc": _h_gstin_crc,
|
| 166 |
+
"abn_mod89": _h_abn_mod89,
|
| 167 |
+
"bn_luhn": _h_bn_luhn,
|
| 168 |
+
"sg_nric_fin": _h_sg_nric_fin,
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def run_tax_id_validation(
|
| 173 |
+
*,
|
| 174 |
+
document,
|
| 175 |
+
extracted_fields,
|
| 176 |
+
vendor,
|
| 177 |
+
vendor_tax_ids,
|
| 178 |
+
resolved_vendor_id,
|
| 179 |
+
platform_configs=None,
|
| 180 |
+
):
|
| 181 |
+
"""
|
| 182 |
+
Runs Tax ID Validation after vendor resolution.
|
| 183 |
+
|
| 184 |
+
Expected document fields:
|
| 185 |
+
document.id
|
| 186 |
+
document.tenant_id
|
| 187 |
+
|
| 188 |
+
Expected extracted_fields fields:
|
| 189 |
+
sender_tax_id
|
| 190 |
+
invoice_date
|
| 191 |
+
|
| 192 |
+
Expected vendor fields:
|
| 193 |
+
vendor.id
|
| 194 |
+
vendor.billing_country
|
| 195 |
+
vendor.tax_registration_number
|
| 196 |
+
|
| 197 |
+
Expected vendor_tax_ids row fields:
|
| 198 |
+
tenant_id
|
| 199 |
+
vendor_id
|
| 200 |
+
tax_id_canonical
|
| 201 |
+
tax_id_type
|
| 202 |
+
verified_at
|
| 203 |
+
deleted_at
|
| 204 |
+
effective_from
|
| 205 |
+
effective_to
|
| 206 |
+
"""
|
| 207 |
+
|
| 208 |
+
platform_configs = platform_configs or {}
|
| 209 |
+
|
| 210 |
+
tax_id_registry = platform_configs.get(
|
| 211 |
+
"validation.tax_id_family_registry",
|
| 212 |
+
{
|
| 213 |
+
"VAT-GB": {"pattern": r"^(GB)?[0-9]{9}([0-9]{3})?$", "checksum": "gb_vat_mod97"},
|
| 214 |
+
"VAT-DE": {"pattern": r"^(DE)?[0-9]{9}$", "checksum": "de_vat_mod11"},
|
| 215 |
+
"VAT-FR": {"pattern": r"^(FR)?[A-Z0-9]{2}[0-9]{9}$", "checksum": "fr_vat_mod97"},
|
| 216 |
+
"VAT-IT": {"pattern": r"^(IT)?[0-9]{11}$", "checksum": "it_vat_luhn"},
|
| 217 |
+
"VAT-ES": {"pattern": r"^(ES)?[A-Z0-9][0-9]{7}[A-Z0-9]$", "checksum": "es_nif"},
|
| 218 |
+
"VAT-NL": {"pattern": r"^(NL)?[0-9]{9}B[0-9]{2}$", "checksum": None},
|
| 219 |
+
"GSTIN-IN": {
|
| 220 |
+
"pattern": r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$",
|
| 221 |
+
"checksum": "gstin_crc",
|
| 222 |
+
},
|
| 223 |
+
"PAN-IN": {"pattern": r"^[A-Z]{5}[0-9]{4}[A-Z]$", "checksum": None},
|
| 224 |
+
"EIN-US": {"pattern": r"^[0-9]{2}-?[0-9]{7}$", "checksum": None},
|
| 225 |
+
"ABN-AU": {"pattern": r"^[0-9]{11}$", "checksum": "abn_mod89"},
|
| 226 |
+
"BN-CA": {"pattern": r"^[0-9]{9}([A-Z]{2}[0-9]{4})?$", "checksum": "bn_luhn"},
|
| 227 |
+
"TIN-SG": {"pattern": r"^(?:[STFG][0-9]{7}[A-Z]|[0-9]{9}[A-Z])$", "checksum": "sg_nric_fin"},
|
| 228 |
+
},
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
# Platform can override built-in handlers via validation.tax_id_checksum_handlers.
|
| 232 |
+
# Each handler: callable(value: str, tax_id_type: str | None, country_code: str | None) -> bool
|
| 233 |
+
checksum_handlers = platform_configs.get("validation.tax_id_checksum_handlers", _BUILTIN_CHECKSUM_HANDLERS)
|
| 234 |
+
|
| 235 |
+
def hash_prefix(value, length=8):
|
| 236 |
+
if not value:
|
| 237 |
+
return None
|
| 238 |
+
return sha256(str(value).encode("utf-8")).hexdigest()[:length]
|
| 239 |
+
|
| 240 |
+
def normalise_tax_id(value):
|
| 241 |
+
if value is None:
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
value = normalize("NFKC", str(value))
|
| 245 |
+
value = re.sub(r"[\s\-\.,/\\:;_]+", "", value)
|
| 246 |
+
value = value.upper().strip()
|
| 247 |
+
return value or None
|
| 248 |
+
|
| 249 |
+
def country_from_tax_id_type(tax_id_type):
|
| 250 |
+
if not tax_id_type or "-" not in tax_id_type:
|
| 251 |
+
return None
|
| 252 |
+
return tax_id_type.split("-")[-1]
|
| 253 |
+
|
| 254 |
+
def registry_entries_for_country(country_code):
|
| 255 |
+
if not country_code:
|
| 256 |
+
return list(tax_id_registry.items())
|
| 257 |
+
|
| 258 |
+
country_code = str(country_code).upper()
|
| 259 |
+
return [
|
| 260 |
+
(tax_id_type, config)
|
| 261 |
+
for tax_id_type, config in tax_id_registry.items()
|
| 262 |
+
if country_from_tax_id_type(tax_id_type) == country_code
|
| 263 |
+
]
|
| 264 |
+
|
| 265 |
+
def remove_country_prefix(value, tax_id_type):
|
| 266 |
+
country_code = country_from_tax_id_type(tax_id_type)
|
| 267 |
+
if country_code and value.startswith(country_code):
|
| 268 |
+
return value[len(country_code):]
|
| 269 |
+
return value
|
| 270 |
+
|
| 271 |
+
def checksum_valid(value, checksum_name, tax_id_type=None, country_code=None):
|
| 272 |
+
"""
|
| 273 |
+
Real checksum gate:
|
| 274 |
+
- if checksum is not configured, treat as valid
|
| 275 |
+
- if a handler is provided by platform configs, use it
|
| 276 |
+
- otherwise fail closed (False) so invalid IDs do not silently pass
|
| 277 |
+
"""
|
| 278 |
+
if checksum_name is None:
|
| 279 |
+
return True
|
| 280 |
+
|
| 281 |
+
handler = checksum_handlers.get(checksum_name)
|
| 282 |
+
if callable(handler):
|
| 283 |
+
return bool(handler(value, tax_id_type=tax_id_type, country_code=country_code))
|
| 284 |
+
|
| 285 |
+
return False
|
| 286 |
+
|
| 287 |
+
def canonicalise_tax_id(value, id_type=None, country_code=None):
|
| 288 |
+
stripped = normalise_tax_id(value)
|
| 289 |
+
|
| 290 |
+
if not stripped:
|
| 291 |
+
return {
|
| 292 |
+
"canonical": None,
|
| 293 |
+
"inferred_id_type": None,
|
| 294 |
+
"country_code": None,
|
| 295 |
+
"format_valid": None,
|
| 296 |
+
"checksum_valid": None,
|
| 297 |
+
"validation_passed": None,
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
resolved_id_type = None
|
| 301 |
+
registry_config = None
|
| 302 |
+
|
| 303 |
+
# Master path: direct lookup on (id_type, country_code).
|
| 304 |
+
if id_type:
|
| 305 |
+
resolved_id_type = id_type
|
| 306 |
+
registry_config = tax_id_registry.get(id_type)
|
| 307 |
+
# Cross-check: country embedded in id_type must match supplied country_code.
|
| 308 |
+
if registry_config and country_code:
|
| 309 |
+
embedded = country_from_tax_id_type(id_type)
|
| 310 |
+
if embedded and str(embedded).upper() != str(country_code).upper():
|
| 311 |
+
registry_config = None
|
| 312 |
+
|
| 313 |
+
# Invoice path: infer by trying registry patterns for the supplied country.
|
| 314 |
+
else:
|
| 315 |
+
for candidate_id_type, candidate_config in registry_entries_for_country(country_code):
|
| 316 |
+
if re.match(candidate_config["pattern"], stripped):
|
| 317 |
+
resolved_id_type = candidate_id_type
|
| 318 |
+
registry_config = candidate_config
|
| 319 |
+
break
|
| 320 |
+
|
| 321 |
+
# No registry entry matched: return stripped canonical value, no format/checksum enforcement.
|
| 322 |
+
if not registry_config:
|
| 323 |
+
return {
|
| 324 |
+
"canonical": stripped,
|
| 325 |
+
"inferred_id_type": None,
|
| 326 |
+
"country_code": None,
|
| 327 |
+
"format_valid": None,
|
| 328 |
+
"checksum_valid": None,
|
| 329 |
+
"validation_passed": None,
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
canonical = remove_country_prefix(stripped, resolved_id_type)
|
| 333 |
+
format_valid = bool(re.match(registry_config["pattern"], stripped))
|
| 334 |
+
checksum_ok = checksum_valid(
|
| 335 |
+
canonical,
|
| 336 |
+
registry_config.get("checksum"),
|
| 337 |
+
tax_id_type=resolved_id_type,
|
| 338 |
+
country_code=country_from_tax_id_type(resolved_id_type),
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
return {
|
| 342 |
+
"canonical": canonical,
|
| 343 |
+
"inferred_id_type": resolved_id_type,
|
| 344 |
+
"country_code": country_from_tax_id_type(resolved_id_type),
|
| 345 |
+
"format_valid": format_valid,
|
| 346 |
+
"checksum_valid": checksum_ok,
|
| 347 |
+
"validation_passed": bool(format_valid and checksum_ok),
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
def is_effective(row, invoice_date):
|
| 351 |
+
if getattr(row, "deleted_at", None) is not None:
|
| 352 |
+
return False
|
| 353 |
+
|
| 354 |
+
if invoice_date is None:
|
| 355 |
+
return False
|
| 356 |
+
|
| 357 |
+
effective_from = getattr(row, "effective_from", None)
|
| 358 |
+
effective_to = getattr(row, "effective_to", None)
|
| 359 |
+
|
| 360 |
+
if effective_from and invoice_date < effective_from:
|
| 361 |
+
return False
|
| 362 |
+
|
| 363 |
+
if effective_to and invoice_date >= effective_to:
|
| 364 |
+
return False
|
| 365 |
+
|
| 366 |
+
return True
|
| 367 |
+
|
| 368 |
+
risk_flags = []
|
| 369 |
+
advisory_flags = []
|
| 370 |
+
skipped_steps = []
|
| 371 |
+
|
| 372 |
+
if resolved_vendor_id is None:
|
| 373 |
+
skipped_steps.append(
|
| 374 |
+
{
|
| 375 |
+
"step": "tax_id_validation",
|
| 376 |
+
"skip_reason": "vendor_unresolved",
|
| 377 |
+
}
|
| 378 |
+
)
|
| 379 |
+
return {
|
| 380 |
+
"risk_flags": risk_flags,
|
| 381 |
+
"advisory_flags": advisory_flags,
|
| 382 |
+
"skipped_steps": skipped_steps,
|
| 383 |
+
"audit_payload": None,
|
| 384 |
+
"match_result": "skipped",
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
invoice_tax_id = getattr(extracted_fields, "sender_tax_id", None)
|
| 388 |
+
if not invoice_tax_id:
|
| 389 |
+
skipped_steps.append(
|
| 390 |
+
{
|
| 391 |
+
"step": "tax_id_validation",
|
| 392 |
+
"skip_reason": "no_invoice_tax_id",
|
| 393 |
+
}
|
| 394 |
+
)
|
| 395 |
+
return {
|
| 396 |
+
"risk_flags": risk_flags,
|
| 397 |
+
"advisory_flags": advisory_flags,
|
| 398 |
+
"skipped_steps": skipped_steps,
|
| 399 |
+
"audit_payload": None,
|
| 400 |
+
"match_result": "skipped",
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
invoice_date = getattr(extracted_fields, "invoice_date", None)
|
| 404 |
+
if isinstance(invoice_date, datetime):
|
| 405 |
+
invoice_date = invoice_date.date()
|
| 406 |
+
elif invoice_date is not None and not isinstance(invoice_date, date):
|
| 407 |
+
# Do not invent today's date; treat unknown types as unavailable.
|
| 408 |
+
invoice_date = None
|
| 409 |
+
|
| 410 |
+
vendor_country = getattr(vendor, "billing_country", None)
|
| 411 |
+
|
| 412 |
+
invoice_canonical_result = canonicalise_tax_id(
|
| 413 |
+
invoice_tax_id,
|
| 414 |
+
id_type=None,
|
| 415 |
+
country_code=vendor_country,
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
invoice_canonical = invoice_canonical_result["canonical"]
|
| 419 |
+
invoice_inferred_id_type = invoice_canonical_result["inferred_id_type"]
|
| 420 |
+
invoice_inferred_country = invoice_canonical_result["country_code"]
|
| 421 |
+
|
| 422 |
+
# §7.3.3: unconstrained inference to detect cross-country format mismatch.
|
| 423 |
+
# The constrained call above only tries patterns for vendor_country, so its
|
| 424 |
+
# inferred_country always equals vendor_country (or is None). A separate
|
| 425 |
+
# country_code=None call tries all registry entries and can identify that the
|
| 426 |
+
# invoice ID looks like a different country's format.
|
| 427 |
+
_global_infer = canonicalise_tax_id(invoice_tax_id, id_type=None, country_code=None)
|
| 428 |
+
_global_inferred_country = _global_infer["country_code"]
|
| 429 |
+
|
| 430 |
+
if not invoice_canonical:
|
| 431 |
+
skipped_steps.append({"step": "tax_id_validation", "skip_reason": "no_invoice_tax_id"})
|
| 432 |
+
return {
|
| 433 |
+
"risk_flags": risk_flags,
|
| 434 |
+
"advisory_flags": advisory_flags,
|
| 435 |
+
"skipped_steps": skipped_steps,
|
| 436 |
+
"audit_payload": None,
|
| 437 |
+
"match_result": "skipped",
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
candidate_rows_for_vendor = [
|
| 441 |
+
row
|
| 442 |
+
for row in vendor_tax_ids
|
| 443 |
+
if getattr(row, "tenant_id", None) == document.tenant_id
|
| 444 |
+
and getattr(row, "vendor_id", None) == resolved_vendor_id
|
| 445 |
+
and getattr(row, "deleted_at", None) is None
|
| 446 |
+
]
|
| 447 |
+
|
| 448 |
+
effective_candidate_rows = [
|
| 449 |
+
row
|
| 450 |
+
for row in candidate_rows_for_vendor
|
| 451 |
+
if is_effective(row, invoice_date)
|
| 452 |
+
]
|
| 453 |
+
|
| 454 |
+
matching_vendor_tax_row = None
|
| 455 |
+
for row in effective_candidate_rows:
|
| 456 |
+
if getattr(row, "tax_id_canonical", None) == invoice_canonical:
|
| 457 |
+
matching_vendor_tax_row = row
|
| 458 |
+
break
|
| 459 |
+
|
| 460 |
+
master_fallback_result = None
|
| 461 |
+
fallback_master_canonical = None
|
| 462 |
+
|
| 463 |
+
if not candidate_rows_for_vendor and getattr(vendor, "tax_registration_number", None):
|
| 464 |
+
master_fallback_result = canonicalise_tax_id(
|
| 465 |
+
vendor.tax_registration_number,
|
| 466 |
+
id_type=None,
|
| 467 |
+
country_code=vendor_country,
|
| 468 |
+
)
|
| 469 |
+
fallback_master_canonical = master_fallback_result["canonical"]
|
| 470 |
+
|
| 471 |
+
match_result = "unmatched"
|
| 472 |
+
master_hash_prefix = None
|
| 473 |
+
audit_id_type = invoice_inferred_id_type
|
| 474 |
+
|
| 475 |
+
if matching_vendor_tax_row:
|
| 476 |
+
match_result = "matched"
|
| 477 |
+
master_hash_prefix = hash_prefix(matching_vendor_tax_row.tax_id_canonical)
|
| 478 |
+
audit_id_type = getattr(matching_vendor_tax_row, "tax_id_type", None)
|
| 479 |
+
|
| 480 |
+
if getattr(matching_vendor_tax_row, "verified_at", None) is None:
|
| 481 |
+
advisory_flags.append(
|
| 482 |
+
{
|
| 483 |
+
"code": "tax_id_unverified_master",
|
| 484 |
+
"severity": "advisory",
|
| 485 |
+
"vendor_id": resolved_vendor_id,
|
| 486 |
+
}
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
# Country mismatch only after a real successful match path.
|
| 490 |
+
if (
|
| 491 |
+
_global_inferred_country
|
| 492 |
+
and vendor_country
|
| 493 |
+
and str(_global_inferred_country).upper() != str(vendor_country).upper()
|
| 494 |
+
):
|
| 495 |
+
risk_flags.append(
|
| 496 |
+
{
|
| 497 |
+
"code": "tax_id_country_mismatch",
|
| 498 |
+
"severity": "medium",
|
| 499 |
+
"weight": 0.25,
|
| 500 |
+
"invoice_country": _global_inferred_country,
|
| 501 |
+
"vendor_country": vendor_country,
|
| 502 |
+
}
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
elif not candidate_rows_for_vendor and fallback_master_canonical:
|
| 506 |
+
# Fallback only when there are no structured vendor_tax_ids rows at all.
|
| 507 |
+
master_hash_prefix = hash_prefix(fallback_master_canonical)
|
| 508 |
+
|
| 509 |
+
if invoice_canonical == fallback_master_canonical:
|
| 510 |
+
match_result = "matched_fallback"
|
| 511 |
+
if (
|
| 512 |
+
_global_inferred_country
|
| 513 |
+
and vendor_country
|
| 514 |
+
and str(_global_inferred_country).upper() != str(vendor_country).upper()
|
| 515 |
+
):
|
| 516 |
+
risk_flags.append(
|
| 517 |
+
{
|
| 518 |
+
"code": "tax_id_country_mismatch",
|
| 519 |
+
"severity": "medium",
|
| 520 |
+
"weight": 0.25,
|
| 521 |
+
"invoice_country": _global_inferred_country,
|
| 522 |
+
"vendor_country": vendor_country,
|
| 523 |
+
}
|
| 524 |
+
)
|
| 525 |
+
else:
|
| 526 |
+
risk_flags.append(
|
| 527 |
+
{
|
| 528 |
+
"code": "tax_id_mismatch",
|
| 529 |
+
"severity": "high",
|
| 530 |
+
"vendor_id": resolved_vendor_id,
|
| 531 |
+
}
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
else:
|
| 535 |
+
risk_flags.append(
|
| 536 |
+
{
|
| 537 |
+
"code": "tax_id_mismatch",
|
| 538 |
+
"severity": "high",
|
| 539 |
+
"vendor_id": resolved_vendor_id,
|
| 540 |
+
}
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
audit_payload = {
|
| 544 |
+
"document_id": document.id,
|
| 545 |
+
"vendor_id": resolved_vendor_id,
|
| 546 |
+
"match_result": match_result,
|
| 547 |
+
"invoice_hash_prefix": hash_prefix(invoice_canonical),
|
| 548 |
+
"master_hash_prefix": master_hash_prefix,
|
| 549 |
+
"id_type": audit_id_type,
|
| 550 |
+
"country_code": invoice_inferred_country or vendor_country,
|
| 551 |
+
"called_at": datetime.now(timezone.utc).isoformat(),
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
return {
|
| 555 |
+
"risk_flags": risk_flags,
|
| 556 |
+
"advisory_flags": advisory_flags,
|
| 557 |
+
"skipped_steps": skipped_steps,
|
| 558 |
+
"audit_payload": audit_payload,
|
| 559 |
+
"match_result": match_result,
|
| 560 |
+
"invoice_tax_id_canonical": invoice_canonical,
|
| 561 |
+
"invoice_tax_id_inferred_type": invoice_inferred_id_type,
|
| 562 |
+
"invoice_tax_id_format_valid": invoice_canonical_result.get("format_valid"),
|
| 563 |
+
"invoice_tax_id_checksum_valid": invoice_canonical_result.get("checksum_valid"),
|
| 564 |
+
}
|
src/Invoice Validation Functions/Vendor Validation.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from decimal import Decimal
|
| 2 |
+
from hashlib import sha256
|
| 3 |
+
from unicodedata import normalize
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
from rapidfuzz import fuzz, process
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def run_vendor_validation(
|
| 11 |
+
*,
|
| 12 |
+
document,
|
| 13 |
+
extracted_fields,
|
| 14 |
+
vendors,
|
| 15 |
+
platform_configs,
|
| 16 |
+
route_contexts=None,
|
| 17 |
+
actor_id=None,
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
Runs Cat 2 vendor validation using RapidFuzz instead of the platform name-match algorithm.
|
| 21 |
+
|
| 22 |
+
Expected document fields:
|
| 23 |
+
document.id
|
| 24 |
+
document.tenant_id
|
| 25 |
+
|
| 26 |
+
Expected extracted_fields fields:
|
| 27 |
+
sender_name
|
| 28 |
+
sender_name_canonical
|
| 29 |
+
sender_tax_id_canonical
|
| 30 |
+
bank_iban_canonical
|
| 31 |
+
|
| 32 |
+
Expected vendor fields:
|
| 33 |
+
id
|
| 34 |
+
tenant_id
|
| 35 |
+
legal_name
|
| 36 |
+
vendor_name
|
| 37 |
+
legal_name_canonical
|
| 38 |
+
billing_country
|
| 39 |
+
billing_address
|
| 40 |
+
vendor_status
|
| 41 |
+
deleted_at
|
| 42 |
+
|
| 43 |
+
Returns a dict containing:
|
| 44 |
+
resolved_vendor_id
|
| 45 |
+
resolved_sender_name
|
| 46 |
+
resolved_sender_addr
|
| 47 |
+
risk_flags
|
| 48 |
+
advisory_flags
|
| 49 |
+
skipped_steps
|
| 50 |
+
audit_row
|
| 51 |
+
match_result
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
route_contexts = set(route_contexts or [])
|
| 55 |
+
|
| 56 |
+
require_high_tier_contexts = set(
|
| 57 |
+
platform_configs.get(
|
| 58 |
+
"validation.vendor_match_require_high_tier_contexts",
|
| 59 |
+
["contract_match", "bank_validation", "approval_revalidation"],
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
algorithm_version = platform_configs.get(
|
| 64 |
+
"validation.name_match_algorithm_version",
|
| 65 |
+
"rapidfuzz.local.v1",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
high_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_high_threshold", "0.90")))
|
| 69 |
+
medium_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_medium_threshold", "0.75")))
|
| 70 |
+
suggestion_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_suggestion_threshold", "0.60")))
|
| 71 |
+
|
| 72 |
+
def canonicalize(value):
|
| 73 |
+
if not value:
|
| 74 |
+
return ""
|
| 75 |
+
return normalize("NFKC", str(value)).lower().strip()
|
| 76 |
+
|
| 77 |
+
def hash_prefix(value, length=8):
|
| 78 |
+
if not value:
|
| 79 |
+
return None
|
| 80 |
+
return sha256(str(value).encode("utf-8")).hexdigest()[:length]
|
| 81 |
+
|
| 82 |
+
def get_vendor_names(vendor):
|
| 83 |
+
names = []
|
| 84 |
+
|
| 85 |
+
for name in (
|
| 86 |
+
getattr(vendor, "legal_name", None),
|
| 87 |
+
getattr(vendor, "vendor_name", None),
|
| 88 |
+
):
|
| 89 |
+
canonical = canonicalize(name)
|
| 90 |
+
if canonical and canonical not in names:
|
| 91 |
+
names.append(canonical)
|
| 92 |
+
|
| 93 |
+
return names
|
| 94 |
+
|
| 95 |
+
def build_candidate_pool():
|
| 96 |
+
pool = []
|
| 97 |
+
|
| 98 |
+
for vendor in vendors:
|
| 99 |
+
if getattr(vendor, "tenant_id", None) != document.tenant_id:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
if getattr(vendor, "vendor_status", None) == "deleted":
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
if getattr(vendor, "deleted_at", None) is not None:
|
| 106 |
+
continue
|
| 107 |
+
|
| 108 |
+
names = get_vendor_names(vendor)
|
| 109 |
+
if not names:
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
pool.append(
|
| 113 |
+
{
|
| 114 |
+
"record_id": vendor.id,
|
| 115 |
+
"vendor": vendor,
|
| 116 |
+
"names": names,
|
| 117 |
+
"auxiliary": {
|
| 118 |
+
"country": getattr(vendor, "billing_country", None),
|
| 119 |
+
"tax_id_hash": hash_prefix(
|
| 120 |
+
getattr(extracted_fields, "sender_tax_id_canonical", None)
|
| 121 |
+
),
|
| 122 |
+
"iban_hash": hash_prefix(
|
| 123 |
+
getattr(extracted_fields, "bank_iban_canonical", None)
|
| 124 |
+
),
|
| 125 |
+
},
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
return pool
|
| 130 |
+
|
| 131 |
+
def rapidfuzz_name_match(query_name, candidate_pool):
|
| 132 |
+
choices = []
|
| 133 |
+
|
| 134 |
+
for candidate in candidate_pool:
|
| 135 |
+
for name in candidate["names"]:
|
| 136 |
+
choices.append(
|
| 137 |
+
{
|
| 138 |
+
"name": name,
|
| 139 |
+
"record_id": candidate["record_id"],
|
| 140 |
+
"vendor": candidate["vendor"],
|
| 141 |
+
}
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
if not query_name or not choices:
|
| 145 |
+
return {
|
| 146 |
+
"decision": "unmatched",
|
| 147 |
+
"matched_record_id": None,
|
| 148 |
+
"matched_vendor": None,
|
| 149 |
+
"runner_up_record_id": None,
|
| 150 |
+
"runner_up_vendor": None,
|
| 151 |
+
"score": Decimal("0"),
|
| 152 |
+
"score_tier": "low",
|
| 153 |
+
"algorithm_version": algorithm_version,
|
| 154 |
+
"trace_id": str(uuid4()),
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
raw_matches = process.extract(
|
| 158 |
+
query_name,
|
| 159 |
+
[choice["name"] for choice in choices],
|
| 160 |
+
scorer=fuzz.WRatio,
|
| 161 |
+
limit=5,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
ranked = []
|
| 165 |
+
seen_record_ids = set()
|
| 166 |
+
|
| 167 |
+
for matched_name, score, choice_index in raw_matches:
|
| 168 |
+
choice = choices[choice_index]
|
| 169 |
+
record_id = choice["record_id"]
|
| 170 |
+
|
| 171 |
+
if record_id in seen_record_ids:
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
seen_record_ids.add(record_id)
|
| 175 |
+
|
| 176 |
+
ranked.append(
|
| 177 |
+
{
|
| 178 |
+
"record_id": record_id,
|
| 179 |
+
"vendor": choice["vendor"],
|
| 180 |
+
"matched_name": matched_name,
|
| 181 |
+
"score": Decimal(str(score / 100)).quantize(Decimal("0.0001")),
|
| 182 |
+
}
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
if not ranked:
|
| 186 |
+
best = None
|
| 187 |
+
runner_up = None
|
| 188 |
+
score = Decimal("0")
|
| 189 |
+
else:
|
| 190 |
+
best = ranked[0]
|
| 191 |
+
runner_up = ranked[1] if len(ranked) > 1 else None
|
| 192 |
+
score = best["score"]
|
| 193 |
+
|
| 194 |
+
if score >= high_threshold:
|
| 195 |
+
decision = "matched"
|
| 196 |
+
score_tier = "high"
|
| 197 |
+
matched_record_id = best["record_id"]
|
| 198 |
+
matched_vendor = best["vendor"]
|
| 199 |
+
elif score >= medium_threshold:
|
| 200 |
+
decision = "matched"
|
| 201 |
+
score_tier = "medium"
|
| 202 |
+
matched_record_id = best["record_id"]
|
| 203 |
+
matched_vendor = best["vendor"]
|
| 204 |
+
elif score >= suggestion_threshold:
|
| 205 |
+
decision = "suggestion"
|
| 206 |
+
score_tier = "low"
|
| 207 |
+
matched_record_id = None
|
| 208 |
+
matched_vendor = None
|
| 209 |
+
else:
|
| 210 |
+
decision = "unmatched"
|
| 211 |
+
score_tier = "low"
|
| 212 |
+
matched_record_id = None
|
| 213 |
+
matched_vendor = None
|
| 214 |
+
|
| 215 |
+
return {
|
| 216 |
+
"decision": decision,
|
| 217 |
+
"matched_record_id": matched_record_id,
|
| 218 |
+
"matched_vendor": matched_vendor,
|
| 219 |
+
"runner_up_record_id": (
|
| 220 |
+
best["record_id"] if decision == "suggestion" and best
|
| 221 |
+
else (runner_up["record_id"] if runner_up else None)
|
| 222 |
+
),
|
| 223 |
+
"runner_up_vendor": (
|
| 224 |
+
best["vendor"] if decision == "suggestion" and best
|
| 225 |
+
else (runner_up["vendor"] if runner_up else None)
|
| 226 |
+
),
|
| 227 |
+
"score": score,
|
| 228 |
+
"score_tier": score_tier,
|
| 229 |
+
"algorithm_version": algorithm_version,
|
| 230 |
+
"trace_id": str(uuid4()),
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
risk_flags = []
|
| 234 |
+
advisory_flags = []
|
| 235 |
+
skipped_steps = []
|
| 236 |
+
|
| 237 |
+
resolved_vendor_id = None
|
| 238 |
+
resolved_sender_name = None
|
| 239 |
+
resolved_sender_addr = None
|
| 240 |
+
|
| 241 |
+
query_name = canonicalize(
|
| 242 |
+
getattr(extracted_fields, "sender_name_canonical", None)
|
| 243 |
+
or getattr(extracted_fields, "sender_name", None)
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
candidate_pool = build_candidate_pool()
|
| 247 |
+
|
| 248 |
+
match_result = rapidfuzz_name_match(
|
| 249 |
+
query_name=query_name,
|
| 250 |
+
candidate_pool=candidate_pool,
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
decision = match_result["decision"]
|
| 254 |
+
score_tier = match_result["score_tier"]
|
| 255 |
+
|
| 256 |
+
control_context_requires_high = bool(route_contexts & require_high_tier_contexts)
|
| 257 |
+
|
| 258 |
+
if decision == "matched" and score_tier == "high":
|
| 259 |
+
resolved_vendor_id = match_result["matched_record_id"]
|
| 260 |
+
|
| 261 |
+
elif decision == "matched" and score_tier == "medium":
|
| 262 |
+
if control_context_requires_high:
|
| 263 |
+
match_result["decision"] = "suggestion"
|
| 264 |
+
advisory_flags.append(
|
| 265 |
+
{
|
| 266 |
+
"code": "vendor_suggestion",
|
| 267 |
+
"severity": "advisory",
|
| 268 |
+
"record_id": match_result["matched_record_id"],
|
| 269 |
+
"reason": "medium_tier_match_downgraded_for_control_context",
|
| 270 |
+
}
|
| 271 |
+
)
|
| 272 |
+
skipped_steps.extend(
|
| 273 |
+
[
|
| 274 |
+
{"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
|
| 275 |
+
{"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
|
| 276 |
+
{"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
|
| 277 |
+
]
|
| 278 |
+
)
|
| 279 |
+
else:
|
| 280 |
+
resolved_vendor_id = match_result["matched_record_id"]
|
| 281 |
+
risk_flags.append(
|
| 282 |
+
{
|
| 283 |
+
"code": "low_confidence_vendor_resolution",
|
| 284 |
+
"severity": "medium",
|
| 285 |
+
"record_id": resolved_vendor_id,
|
| 286 |
+
"score_tier": score_tier,
|
| 287 |
+
}
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
elif decision == "suggestion":
|
| 291 |
+
advisory_flags.append(
|
| 292 |
+
{
|
| 293 |
+
"code": "vendor_suggestion",
|
| 294 |
+
"severity": "advisory",
|
| 295 |
+
"record_id": match_result["runner_up_record_id"],
|
| 296 |
+
"reason": "possible_vendor_match",
|
| 297 |
+
}
|
| 298 |
+
)
|
| 299 |
+
skipped_steps.extend(
|
| 300 |
+
[
|
| 301 |
+
{"step": "sender_field_override", "skip_reason": "vendor_unresolved"},
|
| 302 |
+
{"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
|
| 303 |
+
{"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
|
| 304 |
+
{"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
|
| 305 |
+
]
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
else:
|
| 309 |
+
risk_flags.append(
|
| 310 |
+
{
|
| 311 |
+
"code": "unknown_vendor",
|
| 312 |
+
"severity": "high",
|
| 313 |
+
"reason": "no_vendor_match_found",
|
| 314 |
+
}
|
| 315 |
+
)
|
| 316 |
+
skipped_steps.extend(
|
| 317 |
+
[
|
| 318 |
+
{"step": "sender_field_override", "skip_reason": "vendor_unresolved"},
|
| 319 |
+
{"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
|
| 320 |
+
{"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
|
| 321 |
+
{"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
|
| 322 |
+
]
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
blocked_vendor = None
|
| 326 |
+
|
| 327 |
+
if match_result["decision"] in ("matched", "suggestion"):
|
| 328 |
+
if match_result.get("matched_vendor") and match_result["matched_vendor"].vendor_status == "blocked":
|
| 329 |
+
blocked_vendor = match_result["matched_vendor"]
|
| 330 |
+
|
| 331 |
+
if match_result.get("runner_up_vendor") and match_result["runner_up_vendor"].vendor_status == "blocked":
|
| 332 |
+
blocked_vendor = match_result["runner_up_vendor"]
|
| 333 |
+
|
| 334 |
+
if blocked_vendor:
|
| 335 |
+
risk_flags.append(
|
| 336 |
+
{
|
| 337 |
+
"code": "vendor_blocked",
|
| 338 |
+
"severity": "high",
|
| 339 |
+
"record_id": blocked_vendor.id,
|
| 340 |
+
"reason": "matched_or_suggested_vendor_is_blocked",
|
| 341 |
+
}
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
if resolved_vendor_id:
|
| 345 |
+
resolved_vendor = match_result["matched_vendor"]
|
| 346 |
+
|
| 347 |
+
resolved_sender_name = (
|
| 348 |
+
getattr(resolved_vendor, "legal_name", None)
|
| 349 |
+
or getattr(resolved_vendor, "vendor_name", None)
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
billing_address = getattr(resolved_vendor, "billing_address", None)
|
| 353 |
+
if billing_address is not None:
|
| 354 |
+
resolved_sender_addr = billing_address
|
| 355 |
+
|
| 356 |
+
audit_row = {
|
| 357 |
+
"document_id": document.id,
|
| 358 |
+
"query_name_hash_prefix_8": hash_prefix(query_name, length=8),
|
| 359 |
+
"algorithm_version": algorithm_version,
|
| 360 |
+
"decision": match_result["decision"],
|
| 361 |
+
"matched_record_id": match_result["matched_record_id"],
|
| 362 |
+
"runner_up_record_id": match_result["runner_up_record_id"],
|
| 363 |
+
"score_tier": match_result["score_tier"],
|
| 364 |
+
"score": str(match_result["score"]),
|
| 365 |
+
"trace_id": match_result["trace_id"],
|
| 366 |
+
"called_at": datetime.now(timezone.utc),
|
| 367 |
+
"actor_id": actor_id,
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
return {
|
| 371 |
+
"resolved_vendor_id": resolved_vendor_id,
|
| 372 |
+
"resolved_sender_name": resolved_sender_name,
|
| 373 |
+
"resolved_sender_addr": resolved_sender_addr,
|
| 374 |
+
"risk_flags": risk_flags,
|
| 375 |
+
"advisory_flags": advisory_flags,
|
| 376 |
+
"skipped_steps": skipped_steps,
|
| 377 |
+
"audit_row": audit_row,
|
| 378 |
+
"match_result": {
|
| 379 |
+
"decision": match_result["decision"],
|
| 380 |
+
"matched_record_id": resolved_vendor_id,
|
| 381 |
+
"runner_up_record_id": match_result["runner_up_record_id"],
|
| 382 |
+
"score": match_result["score"],
|
| 383 |
+
"score_tier": match_result["score_tier"],
|
| 384 |
+
"algorithm_version": match_result["algorithm_version"],
|
| 385 |
+
"trace_id": match_result["trace_id"],
|
| 386 |
+
},
|
| 387 |
+
}
|
src/Invoice Validation Functions/__pycache__/Bank validation.cpython-311.pyc
ADDED
|
Binary file (15.6 kB). View file
|
|
|
src/Invoice Validation Functions/__pycache__/Tax - ID validation.cpython-311.pyc
ADDED
|
Binary file (21.3 kB). View file
|
|
|
src/Invoice Validation Functions/__pycache__/Vendor Validation.cpython-311.pyc
ADDED
|
Binary file (11.8 kB). View file
|
|
|
src/Invoice Validation Functions/__pycache__/completeness.cpython-311.pyc
ADDED
|
Binary file (6.64 kB). View file
|
|
|
src/Invoice Validation Functions/__pycache__/date_sanity.cpython-311.pyc
ADDED
|
Binary file (7.08 kB). View file
|
|
|
src/Invoice Validation Functions/__pycache__/orchestrator.cpython-311.pyc
ADDED
|
Binary file (11.8 kB). View file
|
|
|
src/Invoice Validation Functions/__pycache__/payment terms valdiation.cpython-311.pyc
ADDED
|
Binary file (7.48 kB). View file
|
|
|
src/Invoice Validation Functions/__pycache__/tax validation.cpython-311.pyc
ADDED
|
Binary file (24 kB). View file
|
|
|
src/Invoice Validation Functions/completeness.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from datetime import date, datetime
|
| 3 |
+
from decimal import Decimal, InvalidOperation
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def parse_date(value):
|
| 7 |
+
if value in (None, ""):
|
| 8 |
+
return None
|
| 9 |
+
if isinstance(value, datetime):
|
| 10 |
+
return value.date()
|
| 11 |
+
if isinstance(value, date):
|
| 12 |
+
return value
|
| 13 |
+
try:
|
| 14 |
+
return datetime.strptime(str(value), "%Y-%m-%d").date()
|
| 15 |
+
except ValueError:
|
| 16 |
+
return None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def parse_decimal(value):
|
| 20 |
+
if value in (None, ""):
|
| 21 |
+
return None
|
| 22 |
+
if isinstance(value, Decimal):
|
| 23 |
+
return value
|
| 24 |
+
try:
|
| 25 |
+
return Decimal(str(value))
|
| 26 |
+
except (InvalidOperation, ValueError):
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def run_invoice_validation(*, document, extracted_fields, line_items, currency_seed_map, platform_configs=None):
|
| 31 |
+
"""
|
| 32 |
+
Business validation logic for:
|
| 33 |
+
7.7.1 Required fields + due-date derivation path
|
| 34 |
+
7.7.2 Currency membership
|
| 35 |
+
7.7.3 Math cross-check with currency-aware tolerance
|
| 36 |
+
"""
|
| 37 |
+
platform_configs = platform_configs or {}
|
| 38 |
+
|
| 39 |
+
cap_minor_units = int(
|
| 40 |
+
platform_configs.get("validation.solver_tolerance.cap_minor_units", 0)
|
| 41 |
+
)
|
| 42 |
+
base_per_minor_unit = int(
|
| 43 |
+
platform_configs.get("validation.solver_tolerance.base_per_minor_unit", 0)
|
| 44 |
+
)
|
| 45 |
+
per_line_minor_units = int(
|
| 46 |
+
platform_configs.get("validation.solver_tolerance.per_line_minor_units", 0)
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
risk_flags = []
|
| 50 |
+
advisory_flags = []
|
| 51 |
+
skipped_steps = []
|
| 52 |
+
|
| 53 |
+
def add_flag(code, *, weight, detail=None):
|
| 54 |
+
risk_flags.append(
|
| 55 |
+
{
|
| 56 |
+
"code": code,
|
| 57 |
+
"severity": "advisory",
|
| 58 |
+
"weight": weight,
|
| 59 |
+
"detail": detail or {},
|
| 60 |
+
}
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
invoice_no = getattr(extracted_fields, "invoice_no", None)
|
| 64 |
+
invoice_date = parse_date(getattr(extracted_fields, "invoice_date", None))
|
| 65 |
+
total_amount = parse_decimal(getattr(extracted_fields, "total_amount", None))
|
| 66 |
+
subtotal = parse_decimal(getattr(extracted_fields, "subtotal", None))
|
| 67 |
+
sender_name = getattr(extracted_fields, "sender_name", None)
|
| 68 |
+
currency = getattr(extracted_fields, "currency", None)
|
| 69 |
+
due_date = parse_date(getattr(extracted_fields, "due_date", None))
|
| 70 |
+
payment_terms = getattr(extracted_fields, "payment_terms", None)
|
| 71 |
+
|
| 72 |
+
missing_fields = []
|
| 73 |
+
|
| 74 |
+
def mark_missing(field_name):
|
| 75 |
+
if field_name not in missing_fields:
|
| 76 |
+
missing_fields.append(field_name)
|
| 77 |
+
|
| 78 |
+
if not invoice_no:
|
| 79 |
+
mark_missing("invoice_no")
|
| 80 |
+
if invoice_date is None:
|
| 81 |
+
mark_missing("invoice_date")
|
| 82 |
+
if total_amount is None:
|
| 83 |
+
mark_missing("total_amount")
|
| 84 |
+
if not sender_name:
|
| 85 |
+
mark_missing("sender_name")
|
| 86 |
+
if not currency:
|
| 87 |
+
mark_missing("currency")
|
| 88 |
+
|
| 89 |
+
due_date_derivable = invoice_date is not None and payment_terms not in (None, "")
|
| 90 |
+
if due_date is None and not due_date_derivable:
|
| 91 |
+
mark_missing("due_date")
|
| 92 |
+
if invoice_date is None:
|
| 93 |
+
mark_missing("invoice_date")
|
| 94 |
+
if payment_terms in (None, ""):
|
| 95 |
+
mark_missing("payment_terms")
|
| 96 |
+
|
| 97 |
+
if missing_fields:
|
| 98 |
+
add_flag(
|
| 99 |
+
"incomplete_fields",
|
| 100 |
+
weight=0.25,
|
| 101 |
+
detail={"missing_fields": missing_fields},
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
currency_key = str(currency).strip().upper() if currency not in (None, "") else None
|
| 105 |
+
currency_row = currency_seed_map.get(currency_key) if currency_key else None
|
| 106 |
+
|
| 107 |
+
if currency_row is None:
|
| 108 |
+
add_flag(
|
| 109 |
+
"invalid_currency",
|
| 110 |
+
weight=0.20,
|
| 111 |
+
detail={
|
| 112 |
+
"currency": currency,
|
| 113 |
+
"reason": "currency_missing_or_not_in_iso_currencies_seed",
|
| 114 |
+
},
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
line_amounts = []
|
| 118 |
+
for li in line_items:
|
| 119 |
+
amount = parse_decimal(getattr(li, "amount", None))
|
| 120 |
+
if amount is not None:
|
| 121 |
+
line_amounts.append(amount)
|
| 122 |
+
|
| 123 |
+
num_line_items = len(line_amounts)
|
| 124 |
+
math_ran = False
|
| 125 |
+
|
| 126 |
+
if subtotal is not None and num_line_items > 0:
|
| 127 |
+
if currency_row is None:
|
| 128 |
+
skipped_steps.append(
|
| 129 |
+
{"step": "math_cross_check", "skip_reason": "currency_minor_unit_unknown"}
|
| 130 |
+
)
|
| 131 |
+
else:
|
| 132 |
+
minor_unit_value = parse_decimal(currency_row.get("minor_unit_value"))
|
| 133 |
+
if minor_unit_value is None:
|
| 134 |
+
skipped_steps.append(
|
| 135 |
+
{"step": "math_cross_check", "skip_reason": "minor_unit_value_missing_in_seed"}
|
| 136 |
+
)
|
| 137 |
+
else:
|
| 138 |
+
math_ran = True
|
| 139 |
+
line_sum = sum(line_amounts, Decimal("0"))
|
| 140 |
+
tolerance_minor_units = min(
|
| 141 |
+
cap_minor_units,
|
| 142 |
+
base_per_minor_unit + per_line_minor_units * num_line_items,
|
| 143 |
+
)
|
| 144 |
+
tolerance_decimal = Decimal(tolerance_minor_units) * minor_unit_value
|
| 145 |
+
difference = abs(line_sum - subtotal)
|
| 146 |
+
|
| 147 |
+
if difference > tolerance_decimal:
|
| 148 |
+
add_flag(
|
| 149 |
+
"math_error_line_items",
|
| 150 |
+
weight=0.20,
|
| 151 |
+
detail={
|
| 152 |
+
"subtotal": str(subtotal),
|
| 153 |
+
"line_items_sum": str(line_sum),
|
| 154 |
+
"difference": str(difference),
|
| 155 |
+
"tolerance_minor_units": tolerance_minor_units,
|
| 156 |
+
"tolerance_decimal": str(tolerance_decimal),
|
| 157 |
+
"currency": currency_key,
|
| 158 |
+
"minor_unit_value": str(minor_unit_value),
|
| 159 |
+
"num_line_items": num_line_items,
|
| 160 |
+
},
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
return {
|
| 164 |
+
"risk_flags": risk_flags,
|
| 165 |
+
"advisory_flags": advisory_flags,
|
| 166 |
+
"skipped_steps": skipped_steps,
|
| 167 |
+
"result": "flagged" if risk_flags else "clean",
|
| 168 |
+
"missing_fields": missing_fields,
|
| 169 |
+
"currency_checked": currency_row is not None,
|
| 170 |
+
"math_checked": math_ran,
|
| 171 |
+
}
|
src/Invoice Validation Functions/date_sanity.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date, datetime, timedelta
|
| 2 |
+
from zoneinfo import ZoneInfo
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def run_date_sanity_checks(
|
| 6 |
+
*,
|
| 7 |
+
document,
|
| 8 |
+
extracted_fields,
|
| 9 |
+
platform_configs=None,
|
| 10 |
+
):
|
| 11 |
+
"""
|
| 12 |
+
Runs Date Sanity Checks after date normalisation.
|
| 13 |
+
|
| 14 |
+
Expected document fields:
|
| 15 |
+
document.id
|
| 16 |
+
document.tenant_id
|
| 17 |
+
Optional: document.tenant_timezone
|
| 18 |
+
|
| 19 |
+
Expected extracted_fields fields:
|
| 20 |
+
invoice_date
|
| 21 |
+
due_date
|
| 22 |
+
due_date_origin
|
| 23 |
+
payment_terms
|
| 24 |
+
service_start_date
|
| 25 |
+
service_end_date
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
dict with advisory risk flags and audit-friendly result.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
platform_configs = platform_configs or {}
|
| 32 |
+
|
| 33 |
+
default_timezone = platform_configs.get(
|
| 34 |
+
"validation.default_timezone",
|
| 35 |
+
getattr(document, "tenant_timezone", "UTC") or "UTC",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
max_invoice_age_days = int(
|
| 39 |
+
platform_configs.get("validation.max_invoice_age_days", 365)
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
max_invoice_age_days = max(30, min(max_invoice_age_days, 1825))
|
| 43 |
+
|
| 44 |
+
service_end_grace_days = int(
|
| 45 |
+
platform_configs.get("validation.service_end_post_invoice_grace_days", 0)
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def as_date(value):
|
| 49 |
+
if value is None or value == "":
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
if isinstance(value, datetime):
|
| 53 |
+
return value.date()
|
| 54 |
+
|
| 55 |
+
if isinstance(value, date):
|
| 56 |
+
return value
|
| 57 |
+
|
| 58 |
+
return datetime.strptime(str(value), "%Y-%m-%d").date()
|
| 59 |
+
|
| 60 |
+
def as_int(value):
|
| 61 |
+
if value is None or value == "":
|
| 62 |
+
return None
|
| 63 |
+
try:
|
| 64 |
+
return int(value)
|
| 65 |
+
except (ValueError, TypeError):
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
def tenant_current_date():
|
| 69 |
+
try:
|
| 70 |
+
return datetime.now(ZoneInfo(default_timezone)).date()
|
| 71 |
+
except Exception:
|
| 72 |
+
return datetime.now(ZoneInfo("UTC")).date()
|
| 73 |
+
|
| 74 |
+
def add_flag(code, *, weight, field, detail=None):
|
| 75 |
+
risk_flags.append(
|
| 76 |
+
{
|
| 77 |
+
"code": code,
|
| 78 |
+
"severity": "advisory",
|
| 79 |
+
"weight": weight,
|
| 80 |
+
"field": field,
|
| 81 |
+
"detail": detail or {},
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
risk_flags = []
|
| 86 |
+
advisory_flags = []
|
| 87 |
+
skipped_steps = []
|
| 88 |
+
|
| 89 |
+
invoice_date = as_date(getattr(extracted_fields, "invoice_date", None))
|
| 90 |
+
due_date = as_date(getattr(extracted_fields, "due_date", None))
|
| 91 |
+
service_start_date = as_date(getattr(extracted_fields, "service_start_date", None))
|
| 92 |
+
service_end_date = as_date(getattr(extracted_fields, "service_end_date", None))
|
| 93 |
+
due_date_origin = getattr(extracted_fields, "due_date_origin", None)
|
| 94 |
+
payment_terms = as_int(getattr(extracted_fields, "payment_terms", None))
|
| 95 |
+
|
| 96 |
+
current_date = tenant_current_date()
|
| 97 |
+
|
| 98 |
+
# 1. Payment terms must be non-negative.
|
| 99 |
+
# Zero is allowed: due-on-receipt.
|
| 100 |
+
if payment_terms is not None and payment_terms < 0:
|
| 101 |
+
add_flag(
|
| 102 |
+
"date_anomaly_payment_terms_negative",
|
| 103 |
+
weight=0.20,
|
| 104 |
+
field="payment_terms",
|
| 105 |
+
detail={
|
| 106 |
+
"payment_terms": payment_terms,
|
| 107 |
+
},
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# 2. Due date must be on or after invoice date.
|
| 111 |
+
if invoice_date is not None and due_date is not None:
|
| 112 |
+
if due_date < invoice_date:
|
| 113 |
+
add_flag(
|
| 114 |
+
"date_anomaly_due_before_invoice",
|
| 115 |
+
weight=0.20,
|
| 116 |
+
field="due_date",
|
| 117 |
+
detail={
|
| 118 |
+
"invoice_date": str(invoice_date),
|
| 119 |
+
"due_date": str(due_date),
|
| 120 |
+
},
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
if invoice_date is None:
|
| 124 |
+
skipped_steps.extend([
|
| 125 |
+
{"step": "date_anomaly_invoice_future_dated", "skip_reason": "no_invoice_date"},
|
| 126 |
+
{"step": "date_anomaly_invoice_too_old", "skip_reason": "no_invoice_date"},
|
| 127 |
+
])
|
| 128 |
+
|
| 129 |
+
# 3. Invoice must not be future-dated.
|
| 130 |
+
if invoice_date is not None and invoice_date > current_date:
|
| 131 |
+
add_flag(
|
| 132 |
+
"date_anomaly_invoice_future_dated",
|
| 133 |
+
weight=0.20,
|
| 134 |
+
field="invoice_date",
|
| 135 |
+
detail={
|
| 136 |
+
"invoice_date": str(invoice_date),
|
| 137 |
+
"current_date": str(current_date),
|
| 138 |
+
"timezone": default_timezone,
|
| 139 |
+
},
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# 4. Invoice must not be too old.
|
| 143 |
+
if invoice_date is not None:
|
| 144 |
+
oldest_allowed_date = current_date - timedelta(days=max_invoice_age_days)
|
| 145 |
+
|
| 146 |
+
if invoice_date < oldest_allowed_date:
|
| 147 |
+
add_flag(
|
| 148 |
+
"date_anomaly_invoice_too_old",
|
| 149 |
+
weight=0.15,
|
| 150 |
+
field="invoice_date",
|
| 151 |
+
detail={
|
| 152 |
+
"invoice_date": str(invoice_date),
|
| 153 |
+
"oldest_allowed_date": str(oldest_allowed_date),
|
| 154 |
+
"max_invoice_age_days": max_invoice_age_days,
|
| 155 |
+
},
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# 5. Service period must not be reversed.
|
| 159 |
+
if service_start_date is not None and service_end_date is not None:
|
| 160 |
+
if service_end_date < service_start_date:
|
| 161 |
+
add_flag(
|
| 162 |
+
"date_anomaly_service_period_reversed",
|
| 163 |
+
weight=0.15,
|
| 164 |
+
field="service_end_date",
|
| 165 |
+
detail={
|
| 166 |
+
"service_start_date": str(service_start_date),
|
| 167 |
+
"service_end_date": str(service_end_date),
|
| 168 |
+
},
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# 6. Service end must not be too far after invoice date.
|
| 172 |
+
if service_end_date is not None:
|
| 173 |
+
if invoice_date is None:
|
| 174 |
+
skipped_steps.append(
|
| 175 |
+
{"step": "date_anomaly_service_end_after_invoice", "skip_reason": "no_invoice_date"}
|
| 176 |
+
)
|
| 177 |
+
else:
|
| 178 |
+
allowed_service_end = invoice_date + timedelta(days=service_end_grace_days)
|
| 179 |
+
|
| 180 |
+
if service_end_date > allowed_service_end:
|
| 181 |
+
add_flag(
|
| 182 |
+
"date_anomaly_service_end_after_invoice",
|
| 183 |
+
weight=0.15,
|
| 184 |
+
field="service_end_date",
|
| 185 |
+
detail={
|
| 186 |
+
"invoice_date": str(invoice_date),
|
| 187 |
+
"service_end_date": str(service_end_date),
|
| 188 |
+
"allowed_service_end": str(allowed_service_end),
|
| 189 |
+
"grace_days": service_end_grace_days,
|
| 190 |
+
},
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# 7. Due date could not be derived.
|
| 194 |
+
if due_date is None and due_date_origin == "unknown":
|
| 195 |
+
add_flag(
|
| 196 |
+
"due_date_not_derivable",
|
| 197 |
+
weight=0.10,
|
| 198 |
+
field="due_date",
|
| 199 |
+
detail={
|
| 200 |
+
"due_date_origin": due_date_origin,
|
| 201 |
+
},
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
"risk_flags": risk_flags,
|
| 206 |
+
"advisory_flags": advisory_flags,
|
| 207 |
+
"skipped_steps": skipped_steps,
|
| 208 |
+
"flag_codes": [flag["code"] for flag in risk_flags],
|
| 209 |
+
"checked_fields": {
|
| 210 |
+
"invoice_date": str(invoice_date) if invoice_date else None,
|
| 211 |
+
"due_date": str(due_date) if due_date else None,
|
| 212 |
+
"due_date_origin": due_date_origin,
|
| 213 |
+
"payment_terms": payment_terms,
|
| 214 |
+
"service_start_date": str(service_start_date) if service_start_date else None,
|
| 215 |
+
"service_end_date": str(service_end_date) if service_end_date else None,
|
| 216 |
+
"current_date": str(current_date),
|
| 217 |
+
"timezone": default_timezone,
|
| 218 |
+
"max_invoice_age_days": max_invoice_age_days,
|
| 219 |
+
"service_end_post_invoice_grace_days": service_end_grace_days,
|
| 220 |
+
},
|
| 221 |
+
}
|
src/Invoice Validation Functions/orchestrator.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Invoice validation orchestrator.
|
| 3 |
+
|
| 4 |
+
Loads platform_configs.json once, runs all seven validators in SDD order,
|
| 5 |
+
wires outputs to downstream inputs, and returns a single consolidated result.
|
| 6 |
+
|
| 7 |
+
No business logic lives here — all decisions remain inside the validator modules.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import importlib.util
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Module loader — handles filenames that contain spaces or hyphens
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
def _load_fn(filename, fn_name):
|
| 20 |
+
"""Import a function from a .py file by path."""
|
| 21 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 22 |
+
path = os.path.join(here, filename)
|
| 23 |
+
spec = importlib.util.spec_from_file_location(filename, path)
|
| 24 |
+
mod = importlib.util.module_from_spec(spec)
|
| 25 |
+
spec.loader.exec_module(mod)
|
| 26 |
+
return getattr(mod, fn_name)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
_run_vendor_validation = _load_fn("Vendor Validation.py", "run_vendor_validation")
|
| 30 |
+
_run_tax_id_validation = _load_fn("Tax - ID validation.py", "run_tax_id_validation")
|
| 31 |
+
_run_bank_validation = _load_fn("Bank validation.py", "run_bank_validation")
|
| 32 |
+
_run_date_sanity_checks = _load_fn("date_sanity.py", "run_date_sanity_checks")
|
| 33 |
+
_run_payment_terms_validation = _load_fn("payment terms valdiation.py", "run_payment_terms_validation")
|
| 34 |
+
_run_tax_amount_validation = _load_fn("tax validation.py", "run_tax_amount_validation")
|
| 35 |
+
_run_completeness_validation = _load_fn("completeness.py", "run_invoice_validation")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Helpers
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
class _FieldProxy:
|
| 43 |
+
"""
|
| 44 |
+
Overlays computed attributes onto an existing extracted_fields object so
|
| 45 |
+
downstream validators can read Tax-ID results via the normal
|
| 46 |
+
getattr(extracted_fields, ...) pattern without mutating the original object.
|
| 47 |
+
|
| 48 |
+
Only non-None values are overlaid; None values fall through to the base
|
| 49 |
+
object (and its getattr default), which preserves the lenient defaults each
|
| 50 |
+
validator defines for fields it cannot always receive.
|
| 51 |
+
"""
|
| 52 |
+
__slots__ = ("_base", "_overlay")
|
| 53 |
+
|
| 54 |
+
def __init__(self, base, **overlay):
|
| 55 |
+
object.__setattr__(self, "_base", base)
|
| 56 |
+
object.__setattr__(self, "_overlay", {k: v for k, v in overlay.items() if v is not None})
|
| 57 |
+
|
| 58 |
+
def __getattr__(self, name):
|
| 59 |
+
overlay = object.__getattribute__(self, "_overlay")
|
| 60 |
+
if name in overlay:
|
| 61 |
+
return overlay[name]
|
| 62 |
+
return getattr(object.__getattribute__(self, "_base"), name)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _load_platform_configs(path=None):
|
| 66 |
+
if path is None:
|
| 67 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 68 |
+
path = os.path.join(here, "platform_configs.json")
|
| 69 |
+
with open(path, encoding="utf-8") as fh:
|
| 70 |
+
return json.load(fh)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _collect(result, key):
|
| 74 |
+
"""Safely pull a list from a module result dict (returns [] if absent)."""
|
| 75 |
+
return result.get(key) or []
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# Pipeline
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
|
| 82 |
+
def run_invoice_validation_pipeline(
|
| 83 |
+
*,
|
| 84 |
+
document,
|
| 85 |
+
extracted_fields,
|
| 86 |
+
vendors,
|
| 87 |
+
vendor_bank_accounts,
|
| 88 |
+
vendor_tax_ids,
|
| 89 |
+
entity,
|
| 90 |
+
line_items,
|
| 91 |
+
purchase_orders,
|
| 92 |
+
contracts,
|
| 93 |
+
tax_master_rows,
|
| 94 |
+
currency_seed_map,
|
| 95 |
+
platform_configs=None,
|
| 96 |
+
route_contexts=None,
|
| 97 |
+
actor_id=None,
|
| 98 |
+
):
|
| 99 |
+
"""
|
| 100 |
+
Run all seven validation modules in SDD order and return a consolidated result.
|
| 101 |
+
|
| 102 |
+
Parameters
|
| 103 |
+
----------
|
| 104 |
+
document : Invoice document object (needs .id, .tenant_id, .entity_id).
|
| 105 |
+
extracted_fields : VLM-extracted fields object.
|
| 106 |
+
vendors : Iterable of vendor records used for vendor matching.
|
| 107 |
+
vendor_bank_accounts : Iterable of vendor bank account records.
|
| 108 |
+
vendor_tax_ids : Iterable of vendor tax-ID records.
|
| 109 |
+
entity : The routing entity object (needs .country_code, .vat_id, .region_code).
|
| 110 |
+
line_items : Iterable of line-item records.
|
| 111 |
+
purchase_orders : Iterable of purchase-order records.
|
| 112 |
+
contracts : Iterable of contract records.
|
| 113 |
+
tax_master_rows : Iterable of tax-master records.
|
| 114 |
+
currency_seed_map : Dict mapping ISO currency code → row with minor_unit_value.
|
| 115 |
+
platform_configs : Pre-loaded config dict; loaded from platform_configs.json when None.
|
| 116 |
+
route_contexts : List of route-context strings forwarded to Vendor Validation.
|
| 117 |
+
actor_id : Actor identifier forwarded to Vendor Validation for audit.
|
| 118 |
+
|
| 119 |
+
Returns
|
| 120 |
+
-------
|
| 121 |
+
{
|
| 122 |
+
"risk_flags": [...], # aggregated, in execution order
|
| 123 |
+
"advisory_flags": [...], # aggregated, in execution order
|
| 124 |
+
"skipped_steps": [...], # aggregated, in execution order
|
| 125 |
+
"ops_alerts": [...], # operational alerts (e.g. tax_master_unconfigured)
|
| 126 |
+
"resolved_vendor_id": ...,
|
| 127 |
+
"module_results": {...}, # raw return dict from each module, keyed by name
|
| 128 |
+
}
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
if platform_configs is None:
|
| 132 |
+
platform_configs = _load_platform_configs()
|
| 133 |
+
|
| 134 |
+
risk_flags = []
|
| 135 |
+
advisory_flags = []
|
| 136 |
+
skipped_steps = []
|
| 137 |
+
ops_alerts = []
|
| 138 |
+
module_results = {}
|
| 139 |
+
|
| 140 |
+
# ------------------------------------------------------------------
|
| 141 |
+
# Step 1 — Vendor Validation
|
| 142 |
+
# Runs first; produces resolved_vendor_id used by all downstream steps.
|
| 143 |
+
# ------------------------------------------------------------------
|
| 144 |
+
vendor_result = _run_vendor_validation(
|
| 145 |
+
document=document,
|
| 146 |
+
extracted_fields=extracted_fields,
|
| 147 |
+
vendors=vendors,
|
| 148 |
+
platform_configs=platform_configs,
|
| 149 |
+
route_contexts=route_contexts,
|
| 150 |
+
actor_id=actor_id,
|
| 151 |
+
)
|
| 152 |
+
module_results["vendor_validation"] = vendor_result
|
| 153 |
+
risk_flags.extend(_collect(vendor_result, "risk_flags"))
|
| 154 |
+
advisory_flags.extend(_collect(vendor_result, "advisory_flags"))
|
| 155 |
+
skipped_steps.extend(_collect(vendor_result, "skipped_steps"))
|
| 156 |
+
|
| 157 |
+
resolved_vendor_id = vendor_result.get("resolved_vendor_id")
|
| 158 |
+
|
| 159 |
+
# Resolve the vendor object so downstream modules receive it directly.
|
| 160 |
+
resolved_vendor = next(
|
| 161 |
+
(v for v in vendors if getattr(v, "id", None) == resolved_vendor_id),
|
| 162 |
+
None,
|
| 163 |
+
) if resolved_vendor_id else None
|
| 164 |
+
|
| 165 |
+
# ------------------------------------------------------------------
|
| 166 |
+
# Step 2 — Tax ID Validation
|
| 167 |
+
# Depends on resolved_vendor_id and the resolved vendor object.
|
| 168 |
+
# ------------------------------------------------------------------
|
| 169 |
+
tax_id_result = _run_tax_id_validation(
|
| 170 |
+
document=document,
|
| 171 |
+
extracted_fields=extracted_fields,
|
| 172 |
+
vendor=resolved_vendor,
|
| 173 |
+
vendor_tax_ids=vendor_tax_ids,
|
| 174 |
+
resolved_vendor_id=resolved_vendor_id,
|
| 175 |
+
platform_configs=platform_configs,
|
| 176 |
+
)
|
| 177 |
+
module_results["tax_id_validation"] = tax_id_result
|
| 178 |
+
risk_flags.extend(_collect(tax_id_result, "risk_flags"))
|
| 179 |
+
advisory_flags.extend(_collect(tax_id_result, "advisory_flags"))
|
| 180 |
+
skipped_steps.extend(_collect(tax_id_result, "skipped_steps"))
|
| 181 |
+
|
| 182 |
+
# Enrich extracted_fields with Tax-ID results so Tax Validation can read
|
| 183 |
+
# sender_tax_id_inferred_family / _format_valid / _checksum_valid via its
|
| 184 |
+
# normal getattr(extracted_fields, ...) pattern.
|
| 185 |
+
enriched_fields = _FieldProxy(
|
| 186 |
+
extracted_fields,
|
| 187 |
+
sender_tax_id_inferred_family=tax_id_result.get("invoice_tax_id_inferred_type"),
|
| 188 |
+
sender_tax_id_format_valid=tax_id_result.get("invoice_tax_id_format_valid"),
|
| 189 |
+
sender_tax_id_checksum_valid=tax_id_result.get("invoice_tax_id_checksum_valid"),
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# ------------------------------------------------------------------
|
| 193 |
+
# Step 3 — Bank Validation
|
| 194 |
+
# Depends on resolved_vendor_id.
|
| 195 |
+
# ------------------------------------------------------------------
|
| 196 |
+
bank_result = _run_bank_validation(
|
| 197 |
+
document=document,
|
| 198 |
+
extracted_fields=extracted_fields,
|
| 199 |
+
vendor_bank_accounts=vendor_bank_accounts,
|
| 200 |
+
resolved_vendor_id=resolved_vendor_id,
|
| 201 |
+
platform_configs=platform_configs,
|
| 202 |
+
)
|
| 203 |
+
module_results["bank_validation"] = bank_result
|
| 204 |
+
risk_flags.extend(_collect(bank_result, "risk_flags"))
|
| 205 |
+
advisory_flags.extend(_collect(bank_result, "advisory_flags"))
|
| 206 |
+
skipped_steps.extend(_collect(bank_result, "skipped_steps"))
|
| 207 |
+
|
| 208 |
+
# ------------------------------------------------------------------
|
| 209 |
+
# Step 4 — Date Sanity Checks
|
| 210 |
+
# Independent of vendor resolution.
|
| 211 |
+
# ------------------------------------------------------------------
|
| 212 |
+
date_result = _run_date_sanity_checks(
|
| 213 |
+
document=document,
|
| 214 |
+
extracted_fields=extracted_fields,
|
| 215 |
+
platform_configs=platform_configs,
|
| 216 |
+
)
|
| 217 |
+
module_results["date_sanity"] = date_result
|
| 218 |
+
risk_flags.extend(_collect(date_result, "risk_flags"))
|
| 219 |
+
advisory_flags.extend(_collect(date_result, "advisory_flags"))
|
| 220 |
+
skipped_steps.extend(_collect(date_result, "skipped_steps"))
|
| 221 |
+
|
| 222 |
+
# ------------------------------------------------------------------
|
| 223 |
+
# Step 5 — Payment Terms Validation
|
| 224 |
+
# Depends on resolved_vendor_id and the resolved vendor object.
|
| 225 |
+
# ------------------------------------------------------------------
|
| 226 |
+
payment_terms_result = _run_payment_terms_validation(
|
| 227 |
+
document=document,
|
| 228 |
+
extracted_fields=extracted_fields,
|
| 229 |
+
vendor=resolved_vendor,
|
| 230 |
+
purchase_orders=purchase_orders,
|
| 231 |
+
contracts=contracts,
|
| 232 |
+
resolved_vendor_id=resolved_vendor_id,
|
| 233 |
+
platform_configs=platform_configs,
|
| 234 |
+
)
|
| 235 |
+
module_results["payment_terms_validation"] = payment_terms_result
|
| 236 |
+
risk_flags.extend(_collect(payment_terms_result, "risk_flags"))
|
| 237 |
+
advisory_flags.extend(_collect(payment_terms_result, "advisory_flags"))
|
| 238 |
+
skipped_steps.extend(_collect(payment_terms_result, "skipped_steps"))
|
| 239 |
+
|
| 240 |
+
# ------------------------------------------------------------------
|
| 241 |
+
# Step 6 — Tax Amount Validation
|
| 242 |
+
# Depends on resolved vendor object and enriched_fields from Step 2.
|
| 243 |
+
# ------------------------------------------------------------------
|
| 244 |
+
tax_result = _run_tax_amount_validation(
|
| 245 |
+
document=document,
|
| 246 |
+
extracted_fields=enriched_fields,
|
| 247 |
+
line_items=line_items,
|
| 248 |
+
vendor=resolved_vendor,
|
| 249 |
+
entity=entity,
|
| 250 |
+
tax_master_rows=tax_master_rows,
|
| 251 |
+
platform_configs=platform_configs,
|
| 252 |
+
)
|
| 253 |
+
module_results["tax_validation"] = tax_result
|
| 254 |
+
risk_flags.extend(_collect(tax_result, "risk_flags"))
|
| 255 |
+
advisory_flags.extend(_collect(tax_result, "advisory_flags"))
|
| 256 |
+
skipped_steps.extend(_collect(tax_result, "skipped_steps"))
|
| 257 |
+
ops_alerts.extend(_collect(tax_result, "ops_alerts"))
|
| 258 |
+
|
| 259 |
+
# ------------------------------------------------------------------
|
| 260 |
+
# Step 7 — Completeness / Math Cross-check
|
| 261 |
+
# Independent of vendor resolution.
|
| 262 |
+
# ------------------------------------------------------------------
|
| 263 |
+
completeness_result = _run_completeness_validation(
|
| 264 |
+
document=document,
|
| 265 |
+
extracted_fields=extracted_fields,
|
| 266 |
+
line_items=line_items,
|
| 267 |
+
currency_seed_map=currency_seed_map,
|
| 268 |
+
platform_configs=platform_configs,
|
| 269 |
+
)
|
| 270 |
+
module_results["completeness"] = completeness_result
|
| 271 |
+
risk_flags.extend(_collect(completeness_result, "risk_flags"))
|
| 272 |
+
advisory_flags.extend(_collect(completeness_result, "advisory_flags"))
|
| 273 |
+
skipped_steps.extend(_collect(completeness_result, "skipped_steps"))
|
| 274 |
+
|
| 275 |
+
return {
|
| 276 |
+
"risk_flags": risk_flags,
|
| 277 |
+
"advisory_flags": advisory_flags,
|
| 278 |
+
"skipped_steps": skipped_steps,
|
| 279 |
+
"ops_alerts": ops_alerts,
|
| 280 |
+
"resolved_vendor_id": resolved_vendor_id,
|
| 281 |
+
"module_results": module_results,
|
| 282 |
+
}
|
src/Invoice Validation Functions/payment terms valdiation.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date, datetime
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def run_payment_terms_validation(
|
| 5 |
+
*,
|
| 6 |
+
document,
|
| 7 |
+
extracted_fields,
|
| 8 |
+
vendor,
|
| 9 |
+
purchase_orders,
|
| 10 |
+
contracts,
|
| 11 |
+
resolved_vendor_id,
|
| 12 |
+
platform_configs=None,
|
| 13 |
+
):
|
| 14 |
+
"""
|
| 15 |
+
Payment Terms Validation.
|
| 16 |
+
|
| 17 |
+
Expected document fields:
|
| 18 |
+
document.id
|
| 19 |
+
document.tenant_id
|
| 20 |
+
document.entity_id
|
| 21 |
+
|
| 22 |
+
Expected extracted_fields fields:
|
| 23 |
+
payment_terms
|
| 24 |
+
po_number_canonical
|
| 25 |
+
invoice_date
|
| 26 |
+
|
| 27 |
+
Expected vendor fields:
|
| 28 |
+
vendor.id
|
| 29 |
+
vendor.payment_terms
|
| 30 |
+
|
| 31 |
+
Expected purchase_orders fields:
|
| 32 |
+
tenant_id
|
| 33 |
+
po_number_canonical
|
| 34 |
+
payment_terms
|
| 35 |
+
deleted_at
|
| 36 |
+
|
| 37 |
+
Expected contract fields:
|
| 38 |
+
tenant_id
|
| 39 |
+
vendor_id
|
| 40 |
+
entity_id
|
| 41 |
+
status
|
| 42 |
+
payment_terms
|
| 43 |
+
effective_from
|
| 44 |
+
effective_to
|
| 45 |
+
deleted_at
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
dict with advisory flags, skipped status, and resolution detail.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
platform_configs = platform_configs or {}
|
| 52 |
+
|
| 53 |
+
tolerance_days = int(
|
| 54 |
+
platform_configs.get("validation.payment_terms_tolerance_days", 0)
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
def as_int(value):
|
| 58 |
+
if value is None or value == "":
|
| 59 |
+
return None
|
| 60 |
+
try:
|
| 61 |
+
return int(value)
|
| 62 |
+
except (ValueError, TypeError):
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
def as_date(value):
|
| 66 |
+
if value is None or value == "":
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
if isinstance(value, datetime):
|
| 70 |
+
return value.date()
|
| 71 |
+
|
| 72 |
+
if isinstance(value, date):
|
| 73 |
+
return value
|
| 74 |
+
|
| 75 |
+
return datetime.strptime(str(value), "%Y-%m-%d").date()
|
| 76 |
+
|
| 77 |
+
def add_flag(code, *, weight, detail=None):
|
| 78 |
+
risk_flags.append(
|
| 79 |
+
{
|
| 80 |
+
"code": code,
|
| 81 |
+
"severity": "advisory",
|
| 82 |
+
"weight": weight,
|
| 83 |
+
"detail": detail or {},
|
| 84 |
+
}
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
def po_terms_tier():
|
| 88 |
+
po_number = getattr(extracted_fields, "po_number_canonical", None)
|
| 89 |
+
|
| 90 |
+
if not po_number:
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
matching_pos = [
|
| 94 |
+
po
|
| 95 |
+
for po in purchase_orders
|
| 96 |
+
if po.tenant_id == document.tenant_id
|
| 97 |
+
and po.po_number_canonical == po_number
|
| 98 |
+
and getattr(po, "deleted_at", None) is None
|
| 99 |
+
and as_int(getattr(po, "payment_terms", None)) is not None
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
if len(matching_pos) >= 1:
|
| 103 |
+
return {
|
| 104 |
+
"tier": "purchase_order",
|
| 105 |
+
"expected_payment_terms": as_int(matching_pos[0].payment_terms),
|
| 106 |
+
"source_id": getattr(matching_pos[0], "id", None),
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
def contract_terms_tier():
|
| 112 |
+
invoice_date = as_date(getattr(extracted_fields, "invoice_date", None))
|
| 113 |
+
|
| 114 |
+
if not invoice_date:
|
| 115 |
+
skipped_steps.append(
|
| 116 |
+
{"step": "contract_payment_terms", "skip_reason": "no_invoice_date"}
|
| 117 |
+
)
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
matching_contracts = []
|
| 121 |
+
|
| 122 |
+
for contract in contracts:
|
| 123 |
+
if contract.tenant_id != document.tenant_id:
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
if contract.vendor_id != resolved_vendor_id:
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
if contract.entity_id != document.entity_id:
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
if getattr(contract, "status", None) != "active":
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
if getattr(contract, "deleted_at", None) is not None:
|
| 136 |
+
continue
|
| 137 |
+
|
| 138 |
+
effective_from = as_date(getattr(contract, "effective_from", None))
|
| 139 |
+
effective_to = as_date(getattr(contract, "effective_to", None))
|
| 140 |
+
|
| 141 |
+
if effective_from is None or invoice_date < effective_from:
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
if effective_to is not None and invoice_date > effective_to:
|
| 145 |
+
continue
|
| 146 |
+
|
| 147 |
+
matching_contracts.append(contract)
|
| 148 |
+
|
| 149 |
+
if len(matching_contracts) != 1:
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
payment_terms = as_int(getattr(matching_contracts[0], "payment_terms", None))
|
| 153 |
+
if payment_terms is None:
|
| 154 |
+
return None
|
| 155 |
+
|
| 156 |
+
return {
|
| 157 |
+
"tier": "contract",
|
| 158 |
+
"expected_payment_terms": payment_terms,
|
| 159 |
+
"source_id": getattr(matching_contracts[0], "id", None),
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
def vendor_master_terms_tier():
|
| 163 |
+
payment_terms = as_int(getattr(vendor, "payment_terms", None))
|
| 164 |
+
|
| 165 |
+
if payment_terms is None:
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
return {
|
| 169 |
+
"tier": "vendor_master",
|
| 170 |
+
"expected_payment_terms": payment_terms,
|
| 171 |
+
"source_id": getattr(vendor, "id", None),
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
risk_flags = []
|
| 175 |
+
advisory_flags = []
|
| 176 |
+
skipped_steps = []
|
| 177 |
+
|
| 178 |
+
invoice_payment_terms = as_int(getattr(extracted_fields, "payment_terms", None))
|
| 179 |
+
|
| 180 |
+
if invoice_payment_terms is None:
|
| 181 |
+
skipped_steps.append(
|
| 182 |
+
{
|
| 183 |
+
"step": "payment_terms_validation",
|
| 184 |
+
"skip_reason": "invoice_payment_terms_missing",
|
| 185 |
+
}
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
return {
|
| 189 |
+
"risk_flags": risk_flags,
|
| 190 |
+
"advisory_flags": advisory_flags,
|
| 191 |
+
"skipped_steps": skipped_steps,
|
| 192 |
+
"result": "skipped",
|
| 193 |
+
"expected_payment_terms": None,
|
| 194 |
+
"expected_source_tier": None,
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
resolved = (
|
| 198 |
+
po_terms_tier()
|
| 199 |
+
or contract_terms_tier()
|
| 200 |
+
or vendor_master_terms_tier()
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
if resolved is None:
|
| 204 |
+
add_flag(
|
| 205 |
+
"vendor_payment_terms_unconfigured",
|
| 206 |
+
weight=0.10,
|
| 207 |
+
detail={
|
| 208 |
+
"reason": "no_po_contract_or_vendor_payment_terms",
|
| 209 |
+
},
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
return {
|
| 213 |
+
"risk_flags": risk_flags,
|
| 214 |
+
"advisory_flags": advisory_flags,
|
| 215 |
+
"skipped_steps": skipped_steps,
|
| 216 |
+
"result": "unconfigured",
|
| 217 |
+
"expected_payment_terms": None,
|
| 218 |
+
"expected_source_tier": None,
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
expected_payment_terms = resolved["expected_payment_terms"]
|
| 222 |
+
expected_source_tier = resolved["tier"]
|
| 223 |
+
|
| 224 |
+
difference = abs(invoice_payment_terms - expected_payment_terms)
|
| 225 |
+
|
| 226 |
+
if difference > tolerance_days:
|
| 227 |
+
add_flag(
|
| 228 |
+
"payment_terms_mismatch",
|
| 229 |
+
weight=0.20,
|
| 230 |
+
detail={
|
| 231 |
+
"invoice_payment_terms": invoice_payment_terms,
|
| 232 |
+
"expected_payment_terms": expected_payment_terms,
|
| 233 |
+
"difference_days": difference,
|
| 234 |
+
"tolerance_days": tolerance_days,
|
| 235 |
+
"expected_source_tier": expected_source_tier,
|
| 236 |
+
"source_id": resolved.get("source_id"),
|
| 237 |
+
},
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
result = "mismatch"
|
| 241 |
+
else:
|
| 242 |
+
result = "matched"
|
| 243 |
+
|
| 244 |
+
return {
|
| 245 |
+
"risk_flags": risk_flags,
|
| 246 |
+
"advisory_flags": advisory_flags,
|
| 247 |
+
"skipped_steps": skipped_steps,
|
| 248 |
+
"result": result,
|
| 249 |
+
"invoice_payment_terms": invoice_payment_terms,
|
| 250 |
+
"expected_payment_terms": expected_payment_terms,
|
| 251 |
+
"expected_source_tier": expected_source_tier,
|
| 252 |
+
"tolerance_days": tolerance_days,
|
| 253 |
+
}
|
src/Invoice Validation Functions/platform_configs.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment_vendor": "Vendor validation configs",
|
| 3 |
+
|
| 4 |
+
"validation.name_match_algorithm_version": "rapidfuzz.local.v1",
|
| 5 |
+
|
| 6 |
+
"validation.vendor_match_require_high_tier_contexts": [
|
| 7 |
+
"contract_match",
|
| 8 |
+
"bank_validation",
|
| 9 |
+
"approval_revalidation"
|
| 10 |
+
],
|
| 11 |
+
|
| 12 |
+
"validation.rapidfuzz_high_threshold": 0.90,
|
| 13 |
+
"validation.rapidfuzz_medium_threshold": 0.75,
|
| 14 |
+
"validation.rapidfuzz_suggestion_threshold": 0.60,
|
| 15 |
+
|
| 16 |
+
"_comment_tax_id": "Tax ID validation configs",
|
| 17 |
+
"_comment_tax_id_handlers": "Built-in checksum handlers are implemented in Tax - ID validation.py (_BUILTIN_CHECKSUM_HANDLERS). They cover all families with a non-null checksum in the registry: gb_vat_mod97, de_vat_mod11, fr_vat_mod97, it_vat_luhn, es_nif, gstin_crc, abn_mod89, bn_luhn, sg_nric_fin. To override at runtime, supply a callable dict under validation.tax_id_checksum_handlers when building platform_configs.",
|
| 18 |
+
|
| 19 |
+
"validation.tax_id_family_registry": {
|
| 20 |
+
"VAT-GB": {"pattern": "^(GB)?[0-9]{9}([0-9]{3})?$", "checksum": "gb_vat_mod97"},
|
| 21 |
+
"VAT-DE": {"pattern": "^(DE)?[0-9]{9}$", "checksum": "de_vat_mod11"},
|
| 22 |
+
"VAT-FR": {"pattern": "^(FR)?[A-Z0-9]{2}[0-9]{9}$", "checksum": "fr_vat_mod97"},
|
| 23 |
+
"VAT-IT": {"pattern": "^(IT)?[0-9]{11}$", "checksum": "it_vat_luhn"},
|
| 24 |
+
"VAT-ES": {"pattern": "^(ES)?[A-Z0-9][0-9]{7}[A-Z0-9]$", "checksum": "es_nif"},
|
| 25 |
+
"VAT-NL": {"pattern": "^(NL)?[0-9]{9}B[0-9]{2}$", "checksum": null},
|
| 26 |
+
"GSTIN-IN": {"pattern": "^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$", "checksum": "gstin_crc"},
|
| 27 |
+
"PAN-IN": {"pattern": "^[A-Z]{5}[0-9]{4}[A-Z]$", "checksum": null},
|
| 28 |
+
"EIN-US": {"pattern": "^[0-9]{2}-?[0-9]{7}$", "checksum": null},
|
| 29 |
+
"ABN-AU": {"pattern": "^[0-9]{11}$", "checksum": "abn_mod89"},
|
| 30 |
+
"BN-CA": {"pattern": "^[0-9]{9}([A-Z]{2}[0-9]{4})?$", "checksum": "bn_luhn"},
|
| 31 |
+
"TIN-SG": {"pattern": "^(?:[STFG][0-9]{7}[A-Z]|[0-9]{9}[A-Z])$", "checksum": "sg_nric_fin"}
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
"_comment_bank": "Bank validation configs",
|
| 35 |
+
|
| 36 |
+
"validation.iban_checksum_required": true,
|
| 37 |
+
"validation.iban_accno_substring_exempt_countries": ["LC", "MT"],
|
| 38 |
+
|
| 39 |
+
"_comment_date": "Date sanity configs",
|
| 40 |
+
"validation.default_timezone": "Asia/Kolkata",
|
| 41 |
+
"validation.max_invoice_age_days": 365,
|
| 42 |
+
"validation.service_end_post_invoice_grace_days": 0,
|
| 43 |
+
|
| 44 |
+
"_comment_payment": "Payment terms configs",
|
| 45 |
+
"validation.payment_terms_tolerance_days": 2,
|
| 46 |
+
|
| 47 |
+
"_comment_tax": "Tax configs",
|
| 48 |
+
"validation.tax_rate_match_tolerance": "0.0001",
|
| 49 |
+
"iso_currencies.minor_unit_value": {
|
| 50 |
+
"USD": "0.01",
|
| 51 |
+
"GBP": "0.01",
|
| 52 |
+
"EUR": "0.01",
|
| 53 |
+
"INR": "0.01",
|
| 54 |
+
"JPY": "1",
|
| 55 |
+
"BHD": "0.001"
|
| 56 |
+
},
|
| 57 |
+
"validation.eu_member_states": [
|
| 58 |
+
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
| 59 |
+
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
| 60 |
+
"PL", "PT", "RO", "SK", "SI", "ES", "SE"
|
| 61 |
+
],
|
| 62 |
+
|
| 63 |
+
"_comment_completeness": "Completeness configs",
|
| 64 |
+
"validation.solver_tolerance.cap_minor_units": 5,
|
| 65 |
+
"validation.solver_tolerance.base_per_minor_unit": 1,
|
| 66 |
+
"validation.solver_tolerance.per_line_minor_units": 1
|
| 67 |
+
}
|
src/Invoice Validation Functions/tax validation.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from decimal import Decimal, ROUND_HALF_UP
|
| 2 |
+
from datetime import date, datetime
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def run_tax_amount_validation(
|
| 6 |
+
*,
|
| 7 |
+
document,
|
| 8 |
+
extracted_fields,
|
| 9 |
+
line_items,
|
| 10 |
+
vendor,
|
| 11 |
+
entity,
|
| 12 |
+
tax_master_rows,
|
| 13 |
+
platform_configs=None,
|
| 14 |
+
):
|
| 15 |
+
"""
|
| 16 |
+
Tax percentage and tax amount validation.
|
| 17 |
+
|
| 18 |
+
Advisory checks:
|
| 19 |
+
- tax rate exists in tax_master
|
| 20 |
+
- tax amount matches taxable base
|
| 21 |
+
- cross-country routing
|
| 22 |
+
- US sales/use tax advisories
|
| 23 |
+
- UK VAT advisories
|
| 24 |
+
- India GST advisories
|
| 25 |
+
- EU reverse charge
|
| 26 |
+
- vendor withholding advisory
|
| 27 |
+
- vendor tax exemption handling
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
platform_configs = platform_configs or {}
|
| 31 |
+
|
| 32 |
+
tax_rate_tolerance = Decimal(
|
| 33 |
+
str(platform_configs.get("validation.tax_rate_match_tolerance", "0.0001"))
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
currency_minor_units = platform_configs.get(
|
| 37 |
+
"iso_currencies.minor_unit_value",
|
| 38 |
+
{
|
| 39 |
+
"USD": Decimal("0.01"),
|
| 40 |
+
"GBP": Decimal("0.01"),
|
| 41 |
+
"EUR": Decimal("0.01"),
|
| 42 |
+
"INR": Decimal("0.01"),
|
| 43 |
+
"JPY": Decimal("1"),
|
| 44 |
+
"BHD": Decimal("0.001"),
|
| 45 |
+
},
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
eu_member_states = set(
|
| 49 |
+
platform_configs.get(
|
| 50 |
+
"validation.eu_member_states",
|
| 51 |
+
[
|
| 52 |
+
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
| 53 |
+
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
| 54 |
+
"PL", "PT", "RO", "SK", "SI", "ES", "SE",
|
| 55 |
+
],
|
| 56 |
+
)
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
def as_decimal(value):
|
| 60 |
+
if value is None or value == "":
|
| 61 |
+
return None
|
| 62 |
+
try:
|
| 63 |
+
return Decimal(str(value))
|
| 64 |
+
except Exception:
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
def country(value):
|
| 68 |
+
if not value:
|
| 69 |
+
return None
|
| 70 |
+
return str(value).upper()
|
| 71 |
+
|
| 72 |
+
def is_zero(value):
|
| 73 |
+
value = as_decimal(value)
|
| 74 |
+
return value is not None and value == Decimal("0")
|
| 75 |
+
|
| 76 |
+
def is_non_zero(value):
|
| 77 |
+
value = as_decimal(value)
|
| 78 |
+
return value is not None and value != Decimal("0")
|
| 79 |
+
|
| 80 |
+
def invoice_date_value():
|
| 81 |
+
value = getattr(extracted_fields, "invoice_date", None)
|
| 82 |
+
if isinstance(value, datetime):
|
| 83 |
+
return value.date()
|
| 84 |
+
if isinstance(value, date):
|
| 85 |
+
return value
|
| 86 |
+
return None # do not invent today's date
|
| 87 |
+
|
| 88 |
+
def currency_tolerance():
|
| 89 |
+
invoice_currency = country(getattr(extracted_fields, "currency", None))
|
| 90 |
+
raw = currency_minor_units.get(invoice_currency)
|
| 91 |
+
return Decimal(str(raw)) if raw is not None else None
|
| 92 |
+
|
| 93 |
+
def add_flag(code, *, weight=None, detail=None):
|
| 94 |
+
flag = {"code": code, "severity": "advisory"}
|
| 95 |
+
if weight is not None:
|
| 96 |
+
flag["weight"] = weight
|
| 97 |
+
if detail is not None:
|
| 98 |
+
flag["detail"] = detail
|
| 99 |
+
risk_flags.append(flag)
|
| 100 |
+
|
| 101 |
+
def add_advisory(code, *, detail=None):
|
| 102 |
+
advisory = {"code": code, "severity": "advisory"}
|
| 103 |
+
if detail is not None:
|
| 104 |
+
advisory["detail"] = detail
|
| 105 |
+
# avoid duplicates
|
| 106 |
+
if not any(a["code"] == code for a in advisory_flags):
|
| 107 |
+
advisory_flags.append(advisory)
|
| 108 |
+
|
| 109 |
+
def add_skip(step_name, reason):
|
| 110 |
+
skipped_steps.append({"step": step_name, "skip_reason": reason})
|
| 111 |
+
|
| 112 |
+
def effective_tax_master_rows():
|
| 113 |
+
invoice_date = invoice_date_value()
|
| 114 |
+
rows = []
|
| 115 |
+
for row in tax_master_rows:
|
| 116 |
+
if getattr(row, "tenant_id", None) != document.tenant_id:
|
| 117 |
+
continue
|
| 118 |
+
if not getattr(row, "is_active", True):
|
| 119 |
+
continue
|
| 120 |
+
if getattr(row, "deleted_at", None) is not None:
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
# If invoice date is unavailable, do not invent one.
|
| 124 |
+
# Keep only active rows, but skip date-sensitive matching later if needed.
|
| 125 |
+
if invoice_date is not None:
|
| 126 |
+
effective_from = getattr(row, "effective_from", None)
|
| 127 |
+
effective_to = getattr(row, "effective_to", None)
|
| 128 |
+
|
| 129 |
+
if effective_from is not None and effective_from > invoice_date:
|
| 130 |
+
continue
|
| 131 |
+
if effective_to is not None and invoice_date >= effective_to:
|
| 132 |
+
continue
|
| 133 |
+
|
| 134 |
+
rows.append(row)
|
| 135 |
+
return rows
|
| 136 |
+
|
| 137 |
+
def rate_matches(master_rate, invoice_rate):
|
| 138 |
+
master_rate = as_decimal(master_rate)
|
| 139 |
+
invoice_rate = as_decimal(invoice_rate)
|
| 140 |
+
if master_rate is None or invoice_rate is None:
|
| 141 |
+
return False
|
| 142 |
+
return abs(master_rate - invoice_rate) <= tax_rate_tolerance
|
| 143 |
+
|
| 144 |
+
def row_region_matches(row_region, hint_region):
|
| 145 |
+
if row_region is None:
|
| 146 |
+
return True
|
| 147 |
+
return hint_region is not None and str(row_region).upper() == str(hint_region).upper()
|
| 148 |
+
|
| 149 |
+
def find_tax_master_rate(
|
| 150 |
+
*,
|
| 151 |
+
rate,
|
| 152 |
+
country_code,
|
| 153 |
+
region_code=None,
|
| 154 |
+
allowed_tax_types=None,
|
| 155 |
+
reduced_rate_allowed=False,
|
| 156 |
+
):
|
| 157 |
+
country_code = country(country_code)
|
| 158 |
+
matches = []
|
| 159 |
+
|
| 160 |
+
for row in active_tax_master:
|
| 161 |
+
if country(getattr(row, "country_code", None)) != country_code:
|
| 162 |
+
continue
|
| 163 |
+
|
| 164 |
+
if not row_region_matches(getattr(row, "region_code", None), region_code):
|
| 165 |
+
continue
|
| 166 |
+
|
| 167 |
+
if allowed_tax_types is not None and getattr(row, "tax_type", None) not in allowed_tax_types:
|
| 168 |
+
continue
|
| 169 |
+
|
| 170 |
+
if rate_matches(getattr(row, "tax_rate", None), rate):
|
| 171 |
+
matches.append(row)
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
elif reduced_rate_allowed:
|
| 175 |
+
tax_name = str(getattr(row, "tax_name", "") or "").lower()
|
| 176 |
+
if "reduced" in tax_name:
|
| 177 |
+
matches.append(row)
|
| 178 |
+
|
| 179 |
+
return matches
|
| 180 |
+
|
| 181 |
+
def find_india_gst_aggregate_rate(rate, region_code, jurisdiction):
|
| 182 |
+
"""
|
| 183 |
+
Match invoice aggregate GST rate against the sum of applicable component rates:
|
| 184 |
+
inter_state : IGST single-row match
|
| 185 |
+
intra_state : CGST + SGST pair whose rates sum to the invoice rate
|
| 186 |
+
None/unknown: try IGST, then CGST+SGST, then CGST+UTGST
|
| 187 |
+
"""
|
| 188 |
+
|
| 189 |
+
def in_rows(tax_type, rgn=None):
|
| 190 |
+
return [
|
| 191 |
+
r for r in active_tax_master
|
| 192 |
+
if country(getattr(r, "country_code", None)) == "IN"
|
| 193 |
+
and getattr(r, "tax_type", None) == tax_type
|
| 194 |
+
and row_region_matches(getattr(r, "region_code", None), rgn)
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
def sum_pair(type_a, type_b, rgn=None):
|
| 198 |
+
for a in in_rows(type_a, rgn):
|
| 199 |
+
a_rate = as_decimal(getattr(a, "tax_rate", None))
|
| 200 |
+
if a_rate is None:
|
| 201 |
+
continue
|
| 202 |
+
for b in in_rows(type_b, rgn):
|
| 203 |
+
b_rate = as_decimal(getattr(b, "tax_rate", None))
|
| 204 |
+
if b_rate is None:
|
| 205 |
+
continue
|
| 206 |
+
if abs((a_rate + b_rate) - rate) <= tax_rate_tolerance:
|
| 207 |
+
return [a, b]
|
| 208 |
+
return []
|
| 209 |
+
|
| 210 |
+
if jurisdiction == "inter_state":
|
| 211 |
+
for r in in_rows("IGST", region_code):
|
| 212 |
+
if rate_matches(getattr(r, "tax_rate", None), rate):
|
| 213 |
+
return [r]
|
| 214 |
+
return []
|
| 215 |
+
|
| 216 |
+
if jurisdiction == "intra_state":
|
| 217 |
+
return sum_pair("CGST", "SGST", region_code)
|
| 218 |
+
|
| 219 |
+
# Unknown jurisdiction: try all applicable component combinations.
|
| 220 |
+
for r in in_rows("IGST"):
|
| 221 |
+
if rate_matches(getattr(r, "tax_rate", None), rate):
|
| 222 |
+
return [r]
|
| 223 |
+
result = sum_pair("CGST", "SGST")
|
| 224 |
+
if result:
|
| 225 |
+
return result
|
| 226 |
+
return sum_pair("CGST", "UTGST")
|
| 227 |
+
|
| 228 |
+
def all_line_rates_zero():
|
| 229 |
+
for line in line_items:
|
| 230 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 231 |
+
if rate is None:
|
| 232 |
+
continue
|
| 233 |
+
if rate != Decimal("0"):
|
| 234 |
+
return False
|
| 235 |
+
return True
|
| 236 |
+
|
| 237 |
+
def sender_tax_family():
|
| 238 |
+
return getattr(extracted_fields, "sender_tax_id_inferred_family", None)
|
| 239 |
+
|
| 240 |
+
def sender_tax_valid_for_country(expected_family):
|
| 241 |
+
return (
|
| 242 |
+
getattr(extracted_fields, "sender_tax_id", None) is not None
|
| 243 |
+
and sender_tax_family() == expected_family
|
| 244 |
+
and bool(getattr(extracted_fields, "sender_tax_id_format_valid", True))
|
| 245 |
+
and bool(getattr(extracted_fields, "sender_tax_id_checksum_valid", True))
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
def supplier_and_recipient_are_eu():
|
| 249 |
+
return supplier_country in eu_member_states and recipient_country in eu_member_states
|
| 250 |
+
|
| 251 |
+
def eu_reverse_charge_conditions_met():
|
| 252 |
+
if not supplier_and_recipient_are_eu():
|
| 253 |
+
return False
|
| 254 |
+
if supplier_country == recipient_country:
|
| 255 |
+
return False
|
| 256 |
+
|
| 257 |
+
if vendor_exemption_status == "reverse_charge":
|
| 258 |
+
return True
|
| 259 |
+
|
| 260 |
+
supplier_family = f"VAT-{supplier_country}"
|
| 261 |
+
supplier_vat_valid = sender_tax_valid_for_country(supplier_family)
|
| 262 |
+
recipient_vat_present = bool(getattr(entity, "vat_id", None))
|
| 263 |
+
|
| 264 |
+
return (
|
| 265 |
+
supplier_vat_valid
|
| 266 |
+
and recipient_vat_present
|
| 267 |
+
and all_line_rates_zero()
|
| 268 |
+
and is_zero(getattr(extracted_fields, "tax_amount", None))
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
def india_jurisdiction():
|
| 272 |
+
supplier_state = getattr(vendor, "billing_state", None)
|
| 273 |
+
recipient_state = getattr(entity, "region_code", None)
|
| 274 |
+
if not supplier_state or not recipient_state:
|
| 275 |
+
return None
|
| 276 |
+
if str(supplier_state).upper() == str(recipient_state).upper():
|
| 277 |
+
return "intra_state"
|
| 278 |
+
return "inter_state"
|
| 279 |
+
|
| 280 |
+
risk_flags = []
|
| 281 |
+
advisory_flags = []
|
| 282 |
+
skipped_steps = []
|
| 283 |
+
ops_alerts = []
|
| 284 |
+
matched_tax_master_rows = {}
|
| 285 |
+
|
| 286 |
+
active_tax_master = effective_tax_master_rows()
|
| 287 |
+
|
| 288 |
+
supplier_country = country(getattr(vendor, "billing_country", None))
|
| 289 |
+
recipient_country = country(getattr(entity, "country_code", None))
|
| 290 |
+
invoice_currency = country(getattr(extracted_fields, "currency", None))
|
| 291 |
+
vendor_exemption_status = getattr(vendor, "exemption_status", None)
|
| 292 |
+
invoice_date = invoice_date_value()
|
| 293 |
+
|
| 294 |
+
if not active_tax_master:
|
| 295 |
+
ops_alerts.append(
|
| 296 |
+
{
|
| 297 |
+
"code": "alert.tax_master_unconfigured",
|
| 298 |
+
"tenant_id": document.tenant_id,
|
| 299 |
+
}
|
| 300 |
+
)
|
| 301 |
+
return {
|
| 302 |
+
"risk_flags": risk_flags,
|
| 303 |
+
"advisory_flags": advisory_flags,
|
| 304 |
+
"skipped_steps": [
|
| 305 |
+
{"step": "all_tax_checks", "skip_reason": "tax_master_unconfigured"}
|
| 306 |
+
],
|
| 307 |
+
"ops_alerts": ops_alerts,
|
| 308 |
+
"matched_tax_master_rows": matched_tax_master_rows,
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
# Check 1 and Check 2 both skip if supplier country is missing.
|
| 312 |
+
if not supplier_country:
|
| 313 |
+
add_skip("rate_exists_in_master", "supplier_country_missing")
|
| 314 |
+
add_skip("tax_amount_matches_taxable_base", "supplier_country_missing")
|
| 315 |
+
else:
|
| 316 |
+
unique_rates = set()
|
| 317 |
+
|
| 318 |
+
# Gather unique (rate, region) pairs — deduplicated by value per SDD §8.1.
|
| 319 |
+
for line in line_items:
|
| 320 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 321 |
+
if rate is not None and rate != Decimal("0"):
|
| 322 |
+
unique_rates.add(
|
| 323 |
+
(
|
| 324 |
+
rate,
|
| 325 |
+
getattr(line, "region_code_hint", None),
|
| 326 |
+
)
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
# Summary tax rate is checked separately
|
| 330 |
+
summary_rate = as_decimal(getattr(extracted_fields, "tax_rate", None))
|
| 331 |
+
if summary_rate is not None and summary_rate != Decimal("0"):
|
| 332 |
+
unique_rates.add((summary_rate, None))
|
| 333 |
+
|
| 334 |
+
for rate, region_code in unique_rates:
|
| 335 |
+
# India aggregate rate matching is handled entirely in Check 6.
|
| 336 |
+
if supplier_country == "IN":
|
| 337 |
+
continue
|
| 338 |
+
|
| 339 |
+
# US state tax needs a region hint
|
| 340 |
+
if supplier_country == "US" and not region_code:
|
| 341 |
+
add_advisory("us_region_unresolved")
|
| 342 |
+
continue
|
| 343 |
+
|
| 344 |
+
allowed_tax_types = None
|
| 345 |
+
if supplier_country == "GB":
|
| 346 |
+
allowed_tax_types = {"VAT"}
|
| 347 |
+
|
| 348 |
+
reduced_rate_allowed = vendor_exemption_status == "reduced_rate"
|
| 349 |
+
|
| 350 |
+
matches = find_tax_master_rate(
|
| 351 |
+
rate=rate,
|
| 352 |
+
country_code=supplier_country,
|
| 353 |
+
region_code=region_code,
|
| 354 |
+
allowed_tax_types=allowed_tax_types,
|
| 355 |
+
reduced_rate_allowed=reduced_rate_allowed,
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
if matches:
|
| 359 |
+
matched_tax_master_rows[str(rate)] = [getattr(row, "id", None) for row in matches]
|
| 360 |
+
else:
|
| 361 |
+
add_flag(
|
| 362 |
+
"tax_rate_not_in_master",
|
| 363 |
+
weight=Decimal("0.35"),
|
| 364 |
+
detail={
|
| 365 |
+
"rate": str(rate),
|
| 366 |
+
"country": supplier_country,
|
| 367 |
+
"region_code": region_code,
|
| 368 |
+
},
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
# Check 2: Amount matches taxable base.
|
| 373 |
+
if supplier_country:
|
| 374 |
+
tolerance = currency_tolerance()
|
| 375 |
+
if tolerance is None:
|
| 376 |
+
add_skip("tax_amount_matches_taxable_base", "currency_minor_unit_unknown")
|
| 377 |
+
else:
|
| 378 |
+
for line in line_items:
|
| 379 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 380 |
+
amount = as_decimal(getattr(line, "amount", None))
|
| 381 |
+
actual_tax = as_decimal(getattr(line, "tax_amount_per_item", None))
|
| 382 |
+
|
| 383 |
+
if rate is None or amount is None or actual_tax is None:
|
| 384 |
+
continue
|
| 385 |
+
|
| 386 |
+
discount = as_decimal(getattr(line, "discount_amount_per_item", None)) or Decimal("0")
|
| 387 |
+
taxable_base = amount - discount
|
| 388 |
+
expected_tax = taxable_base * (rate / Decimal("100"))
|
| 389 |
+
|
| 390 |
+
if abs(expected_tax - actual_tax) > tolerance:
|
| 391 |
+
add_flag(
|
| 392 |
+
"tax_amount_mismatch",
|
| 393 |
+
weight=Decimal("0.35"),
|
| 394 |
+
detail={
|
| 395 |
+
"line_number": getattr(line, "line_number", None),
|
| 396 |
+
"expected_tax": str(expected_tax.quantize(tolerance, rounding=ROUND_HALF_UP)),
|
| 397 |
+
"actual_tax": str(actual_tax),
|
| 398 |
+
"currency": invoice_currency,
|
| 399 |
+
},
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
# §8.9: vendor_tax_exempt advisory fires when all extracted line rates are zero.
|
| 403 |
+
if vendor_exemption_status == "exempt" and all_line_rates_zero():
|
| 404 |
+
add_advisory("vendor_tax_exempt")
|
| 405 |
+
|
| 406 |
+
# Check 3: Cross-country routing.
|
| 407 |
+
summary_tax_amount = as_decimal(getattr(extracted_fields, "tax_amount", None))
|
| 408 |
+
|
| 409 |
+
if supplier_country is None or summary_tax_amount is None or summary_tax_amount == Decimal("0"):
|
| 410 |
+
add_skip("cross_country_routing", "supplier_country_or_tax_amount_missing_or_zero")
|
| 411 |
+
elif recipient_country is None:
|
| 412 |
+
add_skip("cross_country_routing", "recipient_country_missing")
|
| 413 |
+
elif supplier_country != recipient_country:
|
| 414 |
+
add_flag(
|
| 415 |
+
"cross_country_tax",
|
| 416 |
+
weight=Decimal("0.30"),
|
| 417 |
+
detail={
|
| 418 |
+
"supplier_country": supplier_country,
|
| 419 |
+
"recipient_country": recipient_country,
|
| 420 |
+
},
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
# Check 4: United States.
|
| 424 |
+
if supplier_country == "US" or recipient_country == "US":
|
| 425 |
+
if supplier_country == "US" and summary_tax_amount is not None and summary_tax_amount != Decimal("0"):
|
| 426 |
+
add_flag(
|
| 427 |
+
"us_sales_tax",
|
| 428 |
+
weight=Decimal("0.20"),
|
| 429 |
+
detail={"tax_amount": str(summary_tax_amount)},
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
if (
|
| 433 |
+
recipient_country == "US"
|
| 434 |
+
and supplier_country != recipient_country
|
| 435 |
+
and all_line_rates_zero()
|
| 436 |
+
):
|
| 437 |
+
add_flag(
|
| 438 |
+
"us_use_tax_possibly_owed",
|
| 439 |
+
weight=Decimal("0.15"),
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
# Check 5: United Kingdom.
|
| 443 |
+
if supplier_country == "GB" or recipient_country == "GB":
|
| 444 |
+
for line in line_items:
|
| 445 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 446 |
+
tax_amount = as_decimal(getattr(line, "tax_amount_per_item", None))
|
| 447 |
+
|
| 448 |
+
if rate == Decimal("0"):
|
| 449 |
+
gb_matches = find_tax_master_rate(
|
| 450 |
+
rate=rate,
|
| 451 |
+
country_code="GB",
|
| 452 |
+
region_code=getattr(line, "region_code_hint", None),
|
| 453 |
+
allowed_tax_types={"zero_rated", "exempt", "VAT"},
|
| 454 |
+
reduced_rate_allowed=False,
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
if any(getattr(row, "tax_type", None) == "zero_rated" for row in gb_matches):
|
| 458 |
+
add_advisory("uk_zero_rated", detail={"line_number": getattr(line, "line_number", None)})
|
| 459 |
+
elif tax_amount == Decimal("0") and any(
|
| 460 |
+
getattr(row, "tax_type", None) == "exempt" for row in gb_matches
|
| 461 |
+
):
|
| 462 |
+
add_advisory("uk_exempt", detail={"line_number": getattr(line, "line_number", None)})
|
| 463 |
+
elif not (
|
| 464 |
+
vendor_exemption_status == "reverse_charge"
|
| 465 |
+
and supplier_and_recipient_are_eu()
|
| 466 |
+
and supplier_country != recipient_country
|
| 467 |
+
) and not (
|
| 468 |
+
vendor_exemption_status == "exempt"
|
| 469 |
+
and all_line_rates_zero()
|
| 470 |
+
):
|
| 471 |
+
add_flag(
|
| 472 |
+
"tax_rate_not_in_master",
|
| 473 |
+
weight=Decimal("0.35"),
|
| 474 |
+
detail={
|
| 475 |
+
"rate": "0",
|
| 476 |
+
"country": "GB",
|
| 477 |
+
"source": getattr(line, "line_number", None),
|
| 478 |
+
},
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
if (
|
| 482 |
+
supplier_country == "GB"
|
| 483 |
+
and recipient_country == "GB"
|
| 484 |
+
and summary_tax_amount is not None
|
| 485 |
+
and summary_tax_amount != Decimal("0")
|
| 486 |
+
and not sender_tax_valid_for_country("VAT-GB")
|
| 487 |
+
):
|
| 488 |
+
add_flag(
|
| 489 |
+
"missing_tax_id_for_local_tax",
|
| 490 |
+
weight=Decimal("0.25"),
|
| 491 |
+
detail={"expected_family": "VAT-GB"},
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
# Check 6: India GST.
|
| 495 |
+
if supplier_country == "IN" or recipient_country == "IN":
|
| 496 |
+
jurisdiction = india_jurisdiction()
|
| 497 |
+
if jurisdiction is None:
|
| 498 |
+
add_advisory("in_gst_jurisdiction_unresolved")
|
| 499 |
+
else:
|
| 500 |
+
add_advisory("in_gst_jurisdiction_inferred", detail={"jurisdiction": jurisdiction})
|
| 501 |
+
|
| 502 |
+
# Sender tax ID must look like GSTIN-IN or PAN-IN
|
| 503 |
+
if sender_tax_family() not in {"GSTIN-IN", "PAN-IN"}:
|
| 504 |
+
add_flag(
|
| 505 |
+
"missing_tax_id_for_local_tax",
|
| 506 |
+
weight=Decimal("0.25"),
|
| 507 |
+
detail={"expected_family": "GSTIN-IN or PAN-IN"},
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
for line in line_items:
|
| 511 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 512 |
+
if rate is None or rate == Decimal("0"):
|
| 513 |
+
continue
|
| 514 |
+
|
| 515 |
+
region_code = None
|
| 516 |
+
if jurisdiction == "intra_state":
|
| 517 |
+
region_code = getattr(vendor, "billing_state", None) or getattr(entity, "region_code", None)
|
| 518 |
+
|
| 519 |
+
matches = find_india_gst_aggregate_rate(rate, region_code, jurisdiction)
|
| 520 |
+
|
| 521 |
+
if matches:
|
| 522 |
+
matched_tax_master_rows[getattr(line, "line_number", None)] = [
|
| 523 |
+
getattr(r, "id", None) for r in matches
|
| 524 |
+
]
|
| 525 |
+
else:
|
| 526 |
+
add_flag(
|
| 527 |
+
"tax_rate_not_in_master",
|
| 528 |
+
weight=Decimal("0.35"),
|
| 529 |
+
detail={
|
| 530 |
+
"rate": str(rate),
|
| 531 |
+
"country": "IN",
|
| 532 |
+
"region_code": region_code,
|
| 533 |
+
"source": getattr(line, "line_number", None),
|
| 534 |
+
},
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
# Check 7: EU reverse charge.
|
| 538 |
+
# If conditions are met, add advisory and retract cross_country_tax raised by Check 3.
|
| 539 |
+
if supplier_and_recipient_are_eu() and supplier_country != recipient_country:
|
| 540 |
+
if eu_reverse_charge_conditions_met():
|
| 541 |
+
add_advisory("eu_reverse_charge")
|
| 542 |
+
risk_flags[:] = [f for f in risk_flags if f["code"] != "cross_country_tax"]
|
| 543 |
+
|
| 544 |
+
# Check 8: Withholding tax advisory.
|
| 545 |
+
if vendor_exemption_status == "withholding_applicable":
|
| 546 |
+
add_advisory("vendor_withholding_applicable")
|
| 547 |
+
|
| 548 |
+
for line in line_items:
|
| 549 |
+
tax_master_id = getattr(line, "tax_master_id", None)
|
| 550 |
+
if not tax_master_id:
|
| 551 |
+
continue
|
| 552 |
+
for row in active_tax_master:
|
| 553 |
+
if getattr(row, "id", None) == tax_master_id and getattr(row, "tax_type", None) == "withholding":
|
| 554 |
+
add_advisory("vendor_withholding_applicable")
|
| 555 |
+
break
|
| 556 |
+
|
| 557 |
+
return {
|
| 558 |
+
"risk_flags": risk_flags,
|
| 559 |
+
"advisory_flags": advisory_flags,
|
| 560 |
+
"skipped_steps": skipped_steps,
|
| 561 |
+
"ops_alerts": ops_alerts,
|
| 562 |
+
"matched_tax_master_rows": matched_tax_master_rows,
|
| 563 |
+
}
|