Spaces:
Running
Running
Delete src/Invoice Validation Functions
Browse files- src/Invoice Validation Functions/Bank validation.py +0 -438
- src/Invoice Validation Functions/Tax - ID validation.py +0 -556
- src/Invoice Validation Functions/Vendor Validation.py +0 -385
- src/Invoice Validation Functions/completeness.py +0 -169
- src/Invoice Validation Functions/date_sanity.py +0 -219
- src/Invoice Validation Functions/orchestrator.py +0 -282
- src/Invoice Validation Functions/payment terms valdiation.py +0 -264
- src/Invoice Validation Functions/platform_configs.json +0 -67
- src/Invoice Validation Functions/tax validation.py +0 -587
src/Invoice Validation Functions/Bank validation.py
DELETED
|
@@ -1,438 +0,0 @@
|
|
| 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 True
|
| 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 |
-
skipped_steps = []
|
| 235 |
-
audit_entries = []
|
| 236 |
-
|
| 237 |
-
if resolved_vendor_id is None:
|
| 238 |
-
skipped_steps.append(
|
| 239 |
-
{
|
| 240 |
-
"step": "bank_validation",
|
| 241 |
-
"skip_reason": "vendor_unresolved",
|
| 242 |
-
}
|
| 243 |
-
)
|
| 244 |
-
|
| 245 |
-
return {
|
| 246 |
-
"risk_flags": risk_flags,
|
| 247 |
-
"skipped_steps": skipped_steps,
|
| 248 |
-
"audit_entries": audit_entries,
|
| 249 |
-
"match_result": "skipped",
|
| 250 |
-
}
|
| 251 |
-
|
| 252 |
-
invoice_date = getattr(extracted_fields, "invoice_date", None)
|
| 253 |
-
|
| 254 |
-
if isinstance(invoice_date, datetime):
|
| 255 |
-
invoice_date = invoice_date.date()
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
invoice_iban = canonicalize_iban(getattr(extracted_fields, "bank_iban", None))
|
| 259 |
-
invoice_acc_no = canonicalize_acc_no(getattr(extracted_fields, "bank_acc_no", None))
|
| 260 |
-
invoice_swift = canonicalize_swift(getattr(extracted_fields, "bank_swift", None))
|
| 261 |
-
invoice_bank_acc_name = getattr(extracted_fields, "bank_acc_name", None)
|
| 262 |
-
invoice_currency = canonicalize_text(getattr(extracted_fields, "currency", None))
|
| 263 |
-
|
| 264 |
-
# 1. Invoice-side pre-format checks.
|
| 265 |
-
if invoice_iban and iban_checksum_required:
|
| 266 |
-
if not iban_checksum_mod97_valid(invoice_iban):
|
| 267 |
-
add_flag(
|
| 268 |
-
"bank_iban_invalid_checksum",
|
| 269 |
-
field="bank_iban",
|
| 270 |
-
weight=Decimal("0.35"),
|
| 271 |
-
invoice_value=invoice_iban,
|
| 272 |
-
)
|
| 273 |
-
|
| 274 |
-
if invoice_iban and invoice_acc_no:
|
| 275 |
-
if not iban_acc_no_consistent(invoice_iban, invoice_acc_no):
|
| 276 |
-
add_flag(
|
| 277 |
-
"bank_iban_accno_inconsistent",
|
| 278 |
-
field="bank_iban/bank_acc_no",
|
| 279 |
-
weight=Decimal("0.35"),
|
| 280 |
-
invoice_value=f"{invoice_iban}|{invoice_acc_no}",
|
| 281 |
-
)
|
| 282 |
-
|
| 283 |
-
# 2. Build same-tenant, same-vendor, active candidate pool.
|
| 284 |
-
candidate_pool = [
|
| 285 |
-
row
|
| 286 |
-
for row in vendor_bank_accounts
|
| 287 |
-
if getattr(row, "tenant_id", None) == document.tenant_id
|
| 288 |
-
and getattr(row, "vendor_id", None) == resolved_vendor_id
|
| 289 |
-
and not getattr(row, "deleted_at", None)
|
| 290 |
-
and is_effective(row, invoice_date)
|
| 291 |
-
]
|
| 292 |
-
|
| 293 |
-
candidate_pool.sort(
|
| 294 |
-
key=lambda row: (
|
| 295 |
-
bool(getattr(row, "is_primary", False)),
|
| 296 |
-
getattr(row, "effective_from", None) or date.max,
|
| 297 |
-
),
|
| 298 |
-
reverse=True,
|
| 299 |
-
)
|
| 300 |
-
|
| 301 |
-
primary_rows = [
|
| 302 |
-
row for row in candidate_pool
|
| 303 |
-
if bool(getattr(row, "is_primary", False))
|
| 304 |
-
]
|
| 305 |
-
|
| 306 |
-
non_primary_rows = [
|
| 307 |
-
row for row in candidate_pool
|
| 308 |
-
if not bool(getattr(row, "is_primary", False))
|
| 309 |
-
]
|
| 310 |
-
|
| 311 |
-
primary = primary_rows[0] if primary_rows else None
|
| 312 |
-
|
| 313 |
-
invoice_has_any_bank_detail = any(
|
| 314 |
-
[
|
| 315 |
-
invoice_iban,
|
| 316 |
-
invoice_acc_no,
|
| 317 |
-
invoice_swift,
|
| 318 |
-
invoice_bank_acc_name,
|
| 319 |
-
]
|
| 320 |
-
)
|
| 321 |
-
|
| 322 |
-
if invoice_has_any_bank_detail and not candidate_pool:
|
| 323 |
-
add_flag(
|
| 324 |
-
"new_bank_details_no_master",
|
| 325 |
-
field="bank_details",
|
| 326 |
-
invoice_value="bank_details_present",
|
| 327 |
-
)
|
| 328 |
-
|
| 329 |
-
return {
|
| 330 |
-
"risk_flags": risk_flags,
|
| 331 |
-
"skipped_steps": skipped_steps,
|
| 332 |
-
"audit_entries": audit_entries,
|
| 333 |
-
"match_result": "no_master_bank_details",
|
| 334 |
-
}
|
| 335 |
-
|
| 336 |
-
# 3. Compare against primary account.
|
| 337 |
-
if primary:
|
| 338 |
-
primary_match = True
|
| 339 |
-
|
| 340 |
-
master_iban = getattr(primary, "iban_canonical", None)
|
| 341 |
-
master_acc_no = getattr(primary, "acc_no_canonical", None)
|
| 342 |
-
master_swift = canonicalize_swift(getattr(primary, "swift_bic", None))
|
| 343 |
-
master_bank_acc_name = getattr(primary, "bank_acc_name", None)
|
| 344 |
-
master_currency = canonicalize_text(getattr(primary, "currency_code", None))
|
| 345 |
-
|
| 346 |
-
if invoice_iban:
|
| 347 |
-
if not master_iban:
|
| 348 |
-
primary_match = False
|
| 349 |
-
add_flag(
|
| 350 |
-
"new_bank_field_type_no_master",
|
| 351 |
-
field="bank_iban",
|
| 352 |
-
invoice_value=invoice_iban,
|
| 353 |
-
primary_or_non_primary="primary",
|
| 354 |
-
)
|
| 355 |
-
elif invoice_iban != master_iban:
|
| 356 |
-
primary_match = False
|
| 357 |
-
add_flag(
|
| 358 |
-
"bank_iban_mismatch",
|
| 359 |
-
field="bank_iban",
|
| 360 |
-
invoice_value=invoice_iban,
|
| 361 |
-
master_value=master_iban,
|
| 362 |
-
primary_or_non_primary="primary",
|
| 363 |
-
)
|
| 364 |
-
|
| 365 |
-
if invoice_acc_no and master_acc_no and invoice_acc_no != master_acc_no:
|
| 366 |
-
primary_match = False
|
| 367 |
-
add_flag(
|
| 368 |
-
"bank_acc_no_mismatch",
|
| 369 |
-
field="bank_acc_no",
|
| 370 |
-
invoice_value=invoice_acc_no,
|
| 371 |
-
master_value=master_acc_no,
|
| 372 |
-
primary_or_non_primary="primary",
|
| 373 |
-
)
|
| 374 |
-
|
| 375 |
-
if invoice_swift and master_swift and invoice_swift != master_swift:
|
| 376 |
-
primary_match = False
|
| 377 |
-
add_flag(
|
| 378 |
-
"bank_swift_mismatch",
|
| 379 |
-
field="bank_swift",
|
| 380 |
-
invoice_value=invoice_swift,
|
| 381 |
-
master_value=master_swift,
|
| 382 |
-
primary_or_non_primary="primary",
|
| 383 |
-
)
|
| 384 |
-
|
| 385 |
-
if invoice_bank_acc_name and master_bank_acc_name:
|
| 386 |
-
if not bank_account_name_matches(invoice_bank_acc_name, master_bank_acc_name):
|
| 387 |
-
primary_match = False
|
| 388 |
-
add_flag(
|
| 389 |
-
"bank_acc_name_mismatch",
|
| 390 |
-
field="bank_acc_name",
|
| 391 |
-
weight=Decimal("0.30"),
|
| 392 |
-
invoice_value=invoice_bank_acc_name,
|
| 393 |
-
master_value=master_bank_acc_name,
|
| 394 |
-
primary_or_non_primary="primary",
|
| 395 |
-
)
|
| 396 |
-
|
| 397 |
-
if invoice_currency and master_currency and invoice_currency != master_currency:
|
| 398 |
-
primary_match = False
|
| 399 |
-
add_flag(
|
| 400 |
-
"bank_currency_mismatch",
|
| 401 |
-
field="currency",
|
| 402 |
-
weight=Decimal("0.20"),
|
| 403 |
-
currency_invoice=invoice_currency,
|
| 404 |
-
currency_master=master_currency,
|
| 405 |
-
primary_or_non_primary="primary",
|
| 406 |
-
)
|
| 407 |
-
|
| 408 |
-
match_result = "primary_match" if primary_match else "primary_mismatch"
|
| 409 |
-
|
| 410 |
-
else:
|
| 411 |
-
match_result = "no_primary_bank_account"
|
| 412 |
-
|
| 413 |
-
secondary_match = None
|
| 414 |
-
for row in non_primary_rows:
|
| 415 |
-
if invoice_iban and getattr(row, "iban_canonical", None) == invoice_iban:
|
| 416 |
-
secondary_match = row
|
| 417 |
-
break
|
| 418 |
-
|
| 419 |
-
if secondary_match:
|
| 420 |
-
add_flag(
|
| 421 |
-
"bank_non_primary_match",
|
| 422 |
-
field="bank_iban",
|
| 423 |
-
weight=Decimal("0.25"),
|
| 424 |
-
match_result="matched_non_primary",
|
| 425 |
-
invoice_value=invoice_iban,
|
| 426 |
-
master_value=getattr(secondary_match, "iban_canonical", None),
|
| 427 |
-
primary_or_non_primary="non_primary",
|
| 428 |
-
)
|
| 429 |
-
|
| 430 |
-
return {
|
| 431 |
-
"risk_flags": risk_flags,
|
| 432 |
-
"skipped_steps": skipped_steps,
|
| 433 |
-
"audit_entries": audit_entries,
|
| 434 |
-
"match_result": match_result,
|
| 435 |
-
"candidate_count": len(candidate_pool),
|
| 436 |
-
"primary_candidate_count": len(primary_rows),
|
| 437 |
-
"non_primary_candidate_count": len(non_primary_rows),
|
| 438 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/Invoice Validation Functions/Tax - ID validation.py
DELETED
|
@@ -1,556 +0,0 @@
|
|
| 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 |
-
if not invoice_canonical:
|
| 423 |
-
skipped_steps.append({"step": "tax_id_validation", "skip_reason": "no_invoice_tax_id"})
|
| 424 |
-
return {
|
| 425 |
-
"risk_flags": risk_flags,
|
| 426 |
-
"advisory_flags": advisory_flags,
|
| 427 |
-
"skipped_steps": skipped_steps,
|
| 428 |
-
"audit_payload": None,
|
| 429 |
-
"match_result": "skipped",
|
| 430 |
-
}
|
| 431 |
-
|
| 432 |
-
candidate_rows_for_vendor = [
|
| 433 |
-
row
|
| 434 |
-
for row in vendor_tax_ids
|
| 435 |
-
if getattr(row, "tenant_id", None) == document.tenant_id
|
| 436 |
-
and getattr(row, "vendor_id", None) == resolved_vendor_id
|
| 437 |
-
and getattr(row, "deleted_at", None) is None
|
| 438 |
-
]
|
| 439 |
-
|
| 440 |
-
effective_candidate_rows = [
|
| 441 |
-
row
|
| 442 |
-
for row in candidate_rows_for_vendor
|
| 443 |
-
if is_effective(row, invoice_date)
|
| 444 |
-
]
|
| 445 |
-
|
| 446 |
-
matching_vendor_tax_row = None
|
| 447 |
-
for row in effective_candidate_rows:
|
| 448 |
-
if getattr(row, "tax_id_canonical", None) == invoice_canonical:
|
| 449 |
-
matching_vendor_tax_row = row
|
| 450 |
-
break
|
| 451 |
-
|
| 452 |
-
master_fallback_result = None
|
| 453 |
-
fallback_master_canonical = None
|
| 454 |
-
|
| 455 |
-
if not candidate_rows_for_vendor and getattr(vendor, "tax_registration_number", None):
|
| 456 |
-
master_fallback_result = canonicalise_tax_id(
|
| 457 |
-
vendor.tax_registration_number,
|
| 458 |
-
id_type=None,
|
| 459 |
-
country_code=vendor_country,
|
| 460 |
-
)
|
| 461 |
-
fallback_master_canonical = master_fallback_result["canonical"]
|
| 462 |
-
|
| 463 |
-
match_result = "unmatched"
|
| 464 |
-
master_hash_prefix = None
|
| 465 |
-
audit_id_type = invoice_inferred_id_type
|
| 466 |
-
|
| 467 |
-
if matching_vendor_tax_row:
|
| 468 |
-
match_result = "matched"
|
| 469 |
-
master_hash_prefix = hash_prefix(matching_vendor_tax_row.tax_id_canonical)
|
| 470 |
-
audit_id_type = getattr(matching_vendor_tax_row, "tax_id_type", None)
|
| 471 |
-
|
| 472 |
-
if getattr(matching_vendor_tax_row, "verified_at", None) is None:
|
| 473 |
-
advisory_flags.append(
|
| 474 |
-
{
|
| 475 |
-
"code": "tax_id_unverified_master",
|
| 476 |
-
"severity": "advisory",
|
| 477 |
-
"vendor_id": resolved_vendor_id,
|
| 478 |
-
}
|
| 479 |
-
)
|
| 480 |
-
|
| 481 |
-
# Country mismatch only after a real successful match path.
|
| 482 |
-
if (
|
| 483 |
-
invoice_inferred_country
|
| 484 |
-
and vendor_country
|
| 485 |
-
and str(invoice_inferred_country).upper() != str(vendor_country).upper()
|
| 486 |
-
):
|
| 487 |
-
risk_flags.append(
|
| 488 |
-
{
|
| 489 |
-
"code": "tax_id_country_mismatch",
|
| 490 |
-
"severity": "medium",
|
| 491 |
-
"weight": 0.25,
|
| 492 |
-
"invoice_country": invoice_inferred_country,
|
| 493 |
-
"vendor_country": vendor_country,
|
| 494 |
-
}
|
| 495 |
-
)
|
| 496 |
-
|
| 497 |
-
elif not candidate_rows_for_vendor and fallback_master_canonical:
|
| 498 |
-
# Fallback only when there are no structured vendor_tax_ids rows at all.
|
| 499 |
-
master_hash_prefix = hash_prefix(fallback_master_canonical)
|
| 500 |
-
|
| 501 |
-
if invoice_canonical == fallback_master_canonical:
|
| 502 |
-
match_result = "matched_fallback"
|
| 503 |
-
if (
|
| 504 |
-
invoice_inferred_country
|
| 505 |
-
and vendor_country
|
| 506 |
-
and str(invoice_inferred_country).upper() != str(vendor_country).upper()
|
| 507 |
-
):
|
| 508 |
-
risk_flags.append(
|
| 509 |
-
{
|
| 510 |
-
"code": "tax_id_country_mismatch",
|
| 511 |
-
"severity": "medium",
|
| 512 |
-
"weight": 0.25,
|
| 513 |
-
"invoice_country": invoice_inferred_country,
|
| 514 |
-
"vendor_country": vendor_country,
|
| 515 |
-
}
|
| 516 |
-
)
|
| 517 |
-
else:
|
| 518 |
-
risk_flags.append(
|
| 519 |
-
{
|
| 520 |
-
"code": "tax_id_mismatch",
|
| 521 |
-
"severity": "high",
|
| 522 |
-
"vendor_id": resolved_vendor_id,
|
| 523 |
-
}
|
| 524 |
-
)
|
| 525 |
-
|
| 526 |
-
else:
|
| 527 |
-
risk_flags.append(
|
| 528 |
-
{
|
| 529 |
-
"code": "tax_id_mismatch",
|
| 530 |
-
"severity": "high",
|
| 531 |
-
"vendor_id": resolved_vendor_id,
|
| 532 |
-
}
|
| 533 |
-
)
|
| 534 |
-
|
| 535 |
-
audit_payload = {
|
| 536 |
-
"document_id": document.id,
|
| 537 |
-
"vendor_id": resolved_vendor_id,
|
| 538 |
-
"match_result": match_result,
|
| 539 |
-
"invoice_hash_prefix": hash_prefix(invoice_canonical),
|
| 540 |
-
"master_hash_prefix": master_hash_prefix,
|
| 541 |
-
"id_type": audit_id_type,
|
| 542 |
-
"country_code": invoice_inferred_country or vendor_country,
|
| 543 |
-
"called_at": datetime.now(timezone.utc).isoformat(),
|
| 544 |
-
}
|
| 545 |
-
|
| 546 |
-
return {
|
| 547 |
-
"risk_flags": risk_flags,
|
| 548 |
-
"advisory_flags": advisory_flags,
|
| 549 |
-
"skipped_steps": skipped_steps,
|
| 550 |
-
"audit_payload": audit_payload,
|
| 551 |
-
"match_result": match_result,
|
| 552 |
-
"invoice_tax_id_canonical": invoice_canonical,
|
| 553 |
-
"invoice_tax_id_inferred_type": invoice_inferred_id_type,
|
| 554 |
-
"invoice_tax_id_format_valid": invoice_canonical_result.get("format_valid"),
|
| 555 |
-
"invoice_tax_id_checksum_valid": invoice_canonical_result.get("checksum_valid"),
|
| 556 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/Invoice Validation Functions/Vendor Validation.py
DELETED
|
@@ -1,385 +0,0 @@
|
|
| 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": runner_up["record_id"] if runner_up else (
|
| 220 |
-
best["record_id"] if decision == "suggestion" and best else None
|
| 221 |
-
),
|
| 222 |
-
"runner_up_vendor": runner_up["vendor"] if runner_up else (
|
| 223 |
-
best["vendor"] if decision == "suggestion" and best else None
|
| 224 |
-
),
|
| 225 |
-
"score": score,
|
| 226 |
-
"score_tier": score_tier,
|
| 227 |
-
"algorithm_version": algorithm_version,
|
| 228 |
-
"trace_id": str(uuid4()),
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
risk_flags = []
|
| 232 |
-
advisory_flags = []
|
| 233 |
-
skipped_steps = []
|
| 234 |
-
|
| 235 |
-
resolved_vendor_id = None
|
| 236 |
-
resolved_sender_name = None
|
| 237 |
-
resolved_sender_addr = None
|
| 238 |
-
|
| 239 |
-
query_name = canonicalize(
|
| 240 |
-
getattr(extracted_fields, "sender_name_canonical", None)
|
| 241 |
-
or getattr(extracted_fields, "sender_name", None)
|
| 242 |
-
)
|
| 243 |
-
|
| 244 |
-
candidate_pool = build_candidate_pool()
|
| 245 |
-
|
| 246 |
-
match_result = rapidfuzz_name_match(
|
| 247 |
-
query_name=query_name,
|
| 248 |
-
candidate_pool=candidate_pool,
|
| 249 |
-
)
|
| 250 |
-
|
| 251 |
-
decision = match_result["decision"]
|
| 252 |
-
score_tier = match_result["score_tier"]
|
| 253 |
-
|
| 254 |
-
control_context_requires_high = bool(route_contexts & require_high_tier_contexts)
|
| 255 |
-
|
| 256 |
-
if decision == "matched" and score_tier == "high":
|
| 257 |
-
resolved_vendor_id = match_result["matched_record_id"]
|
| 258 |
-
|
| 259 |
-
elif decision == "matched" and score_tier == "medium":
|
| 260 |
-
if control_context_requires_high:
|
| 261 |
-
match_result["decision"] = "suggestion"
|
| 262 |
-
advisory_flags.append(
|
| 263 |
-
{
|
| 264 |
-
"code": "vendor_suggestion",
|
| 265 |
-
"severity": "advisory",
|
| 266 |
-
"record_id": match_result["matched_record_id"],
|
| 267 |
-
"reason": "medium_tier_match_downgraded_for_control_context",
|
| 268 |
-
}
|
| 269 |
-
)
|
| 270 |
-
skipped_steps.extend(
|
| 271 |
-
[
|
| 272 |
-
{"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
|
| 273 |
-
{"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
|
| 274 |
-
{"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
|
| 275 |
-
]
|
| 276 |
-
)
|
| 277 |
-
else:
|
| 278 |
-
resolved_vendor_id = match_result["matched_record_id"]
|
| 279 |
-
risk_flags.append(
|
| 280 |
-
{
|
| 281 |
-
"code": "low_confidence_vendor_resolution",
|
| 282 |
-
"severity": "medium",
|
| 283 |
-
"record_id": resolved_vendor_id,
|
| 284 |
-
"score_tier": score_tier,
|
| 285 |
-
}
|
| 286 |
-
)
|
| 287 |
-
|
| 288 |
-
elif decision == "suggestion":
|
| 289 |
-
advisory_flags.append(
|
| 290 |
-
{
|
| 291 |
-
"code": "vendor_suggestion",
|
| 292 |
-
"severity": "advisory",
|
| 293 |
-
"record_id": match_result["runner_up_record_id"],
|
| 294 |
-
"reason": "possible_vendor_match",
|
| 295 |
-
}
|
| 296 |
-
)
|
| 297 |
-
skipped_steps.extend(
|
| 298 |
-
[
|
| 299 |
-
{"step": "sender_field_override", "skip_reason": "vendor_unresolved"},
|
| 300 |
-
{"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
|
| 301 |
-
{"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
|
| 302 |
-
{"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
|
| 303 |
-
]
|
| 304 |
-
)
|
| 305 |
-
|
| 306 |
-
else:
|
| 307 |
-
risk_flags.append(
|
| 308 |
-
{
|
| 309 |
-
"code": "unknown_vendor",
|
| 310 |
-
"severity": "high",
|
| 311 |
-
"reason": "no_vendor_match_found",
|
| 312 |
-
}
|
| 313 |
-
)
|
| 314 |
-
skipped_steps.extend(
|
| 315 |
-
[
|
| 316 |
-
{"step": "sender_field_override", "skip_reason": "vendor_unresolved"},
|
| 317 |
-
{"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
|
| 318 |
-
{"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
|
| 319 |
-
{"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
|
| 320 |
-
]
|
| 321 |
-
)
|
| 322 |
-
|
| 323 |
-
blocked_vendor = None
|
| 324 |
-
|
| 325 |
-
if match_result["decision"] in ("matched", "suggestion"):
|
| 326 |
-
if match_result.get("matched_vendor") and match_result["matched_vendor"].vendor_status == "blocked":
|
| 327 |
-
blocked_vendor = match_result["matched_vendor"]
|
| 328 |
-
|
| 329 |
-
if match_result.get("runner_up_vendor") and match_result["runner_up_vendor"].vendor_status == "blocked":
|
| 330 |
-
blocked_vendor = match_result["runner_up_vendor"]
|
| 331 |
-
|
| 332 |
-
if blocked_vendor:
|
| 333 |
-
risk_flags.append(
|
| 334 |
-
{
|
| 335 |
-
"code": "vendor_blocked",
|
| 336 |
-
"severity": "high",
|
| 337 |
-
"record_id": blocked_vendor.id,
|
| 338 |
-
"reason": "matched_or_suggested_vendor_is_blocked",
|
| 339 |
-
}
|
| 340 |
-
)
|
| 341 |
-
|
| 342 |
-
if resolved_vendor_id:
|
| 343 |
-
resolved_vendor = match_result["matched_vendor"]
|
| 344 |
-
|
| 345 |
-
resolved_sender_name = (
|
| 346 |
-
getattr(resolved_vendor, "legal_name", None)
|
| 347 |
-
or getattr(resolved_vendor, "vendor_name", None)
|
| 348 |
-
)
|
| 349 |
-
|
| 350 |
-
billing_address = getattr(resolved_vendor, "billing_address", None)
|
| 351 |
-
if billing_address is not None:
|
| 352 |
-
resolved_sender_addr = billing_address
|
| 353 |
-
|
| 354 |
-
audit_row = {
|
| 355 |
-
"document_id": document.id,
|
| 356 |
-
"query_name_hash_prefix_8": hash_prefix(query_name, length=8),
|
| 357 |
-
"algorithm_version": algorithm_version,
|
| 358 |
-
"decision": match_result["decision"],
|
| 359 |
-
"matched_record_id": match_result["matched_record_id"],
|
| 360 |
-
"runner_up_record_id": match_result["runner_up_record_id"],
|
| 361 |
-
"score_tier": match_result["score_tier"],
|
| 362 |
-
"score": str(match_result["score"]),
|
| 363 |
-
"trace_id": match_result["trace_id"],
|
| 364 |
-
"called_at": datetime.now(timezone.utc),
|
| 365 |
-
"actor_id": actor_id,
|
| 366 |
-
}
|
| 367 |
-
|
| 368 |
-
return {
|
| 369 |
-
"resolved_vendor_id": resolved_vendor_id,
|
| 370 |
-
"resolved_sender_name": resolved_sender_name,
|
| 371 |
-
"resolved_sender_addr": resolved_sender_addr,
|
| 372 |
-
"risk_flags": risk_flags,
|
| 373 |
-
"advisory_flags": advisory_flags,
|
| 374 |
-
"skipped_steps": skipped_steps,
|
| 375 |
-
"audit_row": audit_row,
|
| 376 |
-
"match_result": {
|
| 377 |
-
"decision": match_result["decision"],
|
| 378 |
-
"matched_record_id": resolved_vendor_id,
|
| 379 |
-
"runner_up_record_id": match_result["runner_up_record_id"],
|
| 380 |
-
"score": match_result["score"],
|
| 381 |
-
"score_tier": match_result["score_tier"],
|
| 382 |
-
"algorithm_version": match_result["algorithm_version"],
|
| 383 |
-
"trace_id": match_result["trace_id"],
|
| 384 |
-
},
|
| 385 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/Invoice Validation Functions/completeness.py
DELETED
|
@@ -1,169 +0,0 @@
|
|
| 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 |
-
skipped_steps = []
|
| 51 |
-
|
| 52 |
-
def add_flag(code, *, weight, detail=None):
|
| 53 |
-
risk_flags.append(
|
| 54 |
-
{
|
| 55 |
-
"code": code,
|
| 56 |
-
"severity": "advisory",
|
| 57 |
-
"weight": weight,
|
| 58 |
-
"detail": detail or {},
|
| 59 |
-
}
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
invoice_no = getattr(extracted_fields, "invoice_no", None)
|
| 63 |
-
invoice_date = parse_date(getattr(extracted_fields, "invoice_date", None))
|
| 64 |
-
total_amount = parse_decimal(getattr(extracted_fields, "total_amount", None))
|
| 65 |
-
subtotal = parse_decimal(getattr(extracted_fields, "subtotal", None))
|
| 66 |
-
sender_name = getattr(extracted_fields, "sender_name", None)
|
| 67 |
-
currency = getattr(extracted_fields, "currency", None)
|
| 68 |
-
due_date = parse_date(getattr(extracted_fields, "due_date", None))
|
| 69 |
-
payment_terms = getattr(extracted_fields, "payment_terms", None)
|
| 70 |
-
|
| 71 |
-
missing_fields = []
|
| 72 |
-
|
| 73 |
-
def mark_missing(field_name):
|
| 74 |
-
if field_name not in missing_fields:
|
| 75 |
-
missing_fields.append(field_name)
|
| 76 |
-
|
| 77 |
-
if not invoice_no:
|
| 78 |
-
mark_missing("invoice_no")
|
| 79 |
-
if invoice_date is None:
|
| 80 |
-
mark_missing("invoice_date")
|
| 81 |
-
if total_amount is None:
|
| 82 |
-
mark_missing("total_amount")
|
| 83 |
-
if not sender_name:
|
| 84 |
-
mark_missing("sender_name")
|
| 85 |
-
if not currency:
|
| 86 |
-
mark_missing("currency")
|
| 87 |
-
|
| 88 |
-
due_date_derivable = invoice_date is not None and payment_terms not in (None, "")
|
| 89 |
-
if due_date is None and not due_date_derivable:
|
| 90 |
-
mark_missing("due_date")
|
| 91 |
-
if invoice_date is None:
|
| 92 |
-
mark_missing("invoice_date")
|
| 93 |
-
if payment_terms in (None, ""):
|
| 94 |
-
mark_missing("payment_terms")
|
| 95 |
-
|
| 96 |
-
if missing_fields:
|
| 97 |
-
add_flag(
|
| 98 |
-
"incomplete_fields",
|
| 99 |
-
weight=0.25,
|
| 100 |
-
detail={"missing_fields": missing_fields},
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
-
currency_key = str(currency).strip().upper() if currency not in (None, "") else None
|
| 104 |
-
currency_row = currency_seed_map.get(currency_key) if currency_key else None
|
| 105 |
-
|
| 106 |
-
if currency_row is None:
|
| 107 |
-
add_flag(
|
| 108 |
-
"invalid_currency",
|
| 109 |
-
weight=0.20,
|
| 110 |
-
detail={
|
| 111 |
-
"currency": currency,
|
| 112 |
-
"reason": "currency_missing_or_not_in_iso_currencies_seed",
|
| 113 |
-
},
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
line_amounts = []
|
| 117 |
-
for li in line_items:
|
| 118 |
-
amount = parse_decimal(getattr(li, "amount", None))
|
| 119 |
-
if amount is not None:
|
| 120 |
-
line_amounts.append(amount)
|
| 121 |
-
|
| 122 |
-
num_line_items = len(line_amounts)
|
| 123 |
-
math_ran = False
|
| 124 |
-
|
| 125 |
-
if subtotal is not None and num_line_items > 0:
|
| 126 |
-
if currency_row is None:
|
| 127 |
-
skipped_steps.append(
|
| 128 |
-
{"step": "math_cross_check", "skip_reason": "currency_minor_unit_unknown"}
|
| 129 |
-
)
|
| 130 |
-
else:
|
| 131 |
-
minor_unit_value = parse_decimal(currency_row.get("minor_unit_value"))
|
| 132 |
-
if minor_unit_value is None:
|
| 133 |
-
skipped_steps.append(
|
| 134 |
-
{"step": "math_cross_check", "skip_reason": "minor_unit_value_missing_in_seed"}
|
| 135 |
-
)
|
| 136 |
-
else:
|
| 137 |
-
math_ran = True
|
| 138 |
-
line_sum = sum(line_amounts, Decimal("0"))
|
| 139 |
-
tolerance_minor_units = min(
|
| 140 |
-
cap_minor_units,
|
| 141 |
-
base_per_minor_unit + per_line_minor_units * num_line_items,
|
| 142 |
-
)
|
| 143 |
-
tolerance_decimal = Decimal(tolerance_minor_units) * minor_unit_value
|
| 144 |
-
difference = abs(line_sum - subtotal)
|
| 145 |
-
|
| 146 |
-
if difference > tolerance_decimal:
|
| 147 |
-
add_flag(
|
| 148 |
-
"math_error_line_items",
|
| 149 |
-
weight=0.20,
|
| 150 |
-
detail={
|
| 151 |
-
"subtotal": str(subtotal),
|
| 152 |
-
"line_items_sum": str(line_sum),
|
| 153 |
-
"difference": str(difference),
|
| 154 |
-
"tolerance_minor_units": tolerance_minor_units,
|
| 155 |
-
"tolerance_decimal": str(tolerance_decimal),
|
| 156 |
-
"currency": currency_key,
|
| 157 |
-
"minor_unit_value": str(minor_unit_value),
|
| 158 |
-
"num_line_items": num_line_items,
|
| 159 |
-
},
|
| 160 |
-
)
|
| 161 |
-
|
| 162 |
-
return {
|
| 163 |
-
"risk_flags": risk_flags,
|
| 164 |
-
"skipped_steps": skipped_steps,
|
| 165 |
-
"result": "flagged" if risk_flags else "clean",
|
| 166 |
-
"missing_fields": missing_fields,
|
| 167 |
-
"currency_checked": currency_row is not None,
|
| 168 |
-
"math_checked": math_ran,
|
| 169 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/Invoice Validation Functions/date_sanity.py
DELETED
|
@@ -1,219 +0,0 @@
|
|
| 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 |
-
skipped_steps = []
|
| 87 |
-
|
| 88 |
-
invoice_date = as_date(getattr(extracted_fields, "invoice_date", None))
|
| 89 |
-
due_date = as_date(getattr(extracted_fields, "due_date", None))
|
| 90 |
-
service_start_date = as_date(getattr(extracted_fields, "service_start_date", None))
|
| 91 |
-
service_end_date = as_date(getattr(extracted_fields, "service_end_date", None))
|
| 92 |
-
due_date_origin = getattr(extracted_fields, "due_date_origin", None)
|
| 93 |
-
payment_terms = as_int(getattr(extracted_fields, "payment_terms", None))
|
| 94 |
-
|
| 95 |
-
current_date = tenant_current_date()
|
| 96 |
-
|
| 97 |
-
# 1. Payment terms must be non-negative.
|
| 98 |
-
# Zero is allowed: due-on-receipt.
|
| 99 |
-
if payment_terms is not None and payment_terms < 0:
|
| 100 |
-
add_flag(
|
| 101 |
-
"date_anomaly_payment_terms_negative",
|
| 102 |
-
weight=0.20,
|
| 103 |
-
field="payment_terms",
|
| 104 |
-
detail={
|
| 105 |
-
"payment_terms": payment_terms,
|
| 106 |
-
},
|
| 107 |
-
)
|
| 108 |
-
|
| 109 |
-
# 2. Due date must be on or after invoice date.
|
| 110 |
-
if invoice_date is not None and due_date is not None:
|
| 111 |
-
if due_date < invoice_date:
|
| 112 |
-
add_flag(
|
| 113 |
-
"date_anomaly_due_before_invoice",
|
| 114 |
-
weight=0.20,
|
| 115 |
-
field="due_date",
|
| 116 |
-
detail={
|
| 117 |
-
"invoice_date": str(invoice_date),
|
| 118 |
-
"due_date": str(due_date),
|
| 119 |
-
},
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
if invoice_date is None:
|
| 123 |
-
skipped_steps.extend([
|
| 124 |
-
{"step": "date_anomaly_invoice_future_dated", "skip_reason": "no_invoice_date"},
|
| 125 |
-
{"step": "date_anomaly_invoice_too_old", "skip_reason": "no_invoice_date"},
|
| 126 |
-
])
|
| 127 |
-
|
| 128 |
-
# 3. Invoice must not be future-dated.
|
| 129 |
-
if invoice_date is not None and invoice_date > current_date:
|
| 130 |
-
add_flag(
|
| 131 |
-
"date_anomaly_invoice_future_dated",
|
| 132 |
-
weight=0.20,
|
| 133 |
-
field="invoice_date",
|
| 134 |
-
detail={
|
| 135 |
-
"invoice_date": str(invoice_date),
|
| 136 |
-
"current_date": str(current_date),
|
| 137 |
-
"timezone": default_timezone,
|
| 138 |
-
},
|
| 139 |
-
)
|
| 140 |
-
|
| 141 |
-
# 4. Invoice must not be too old.
|
| 142 |
-
if invoice_date is not None:
|
| 143 |
-
oldest_allowed_date = current_date - timedelta(days=max_invoice_age_days)
|
| 144 |
-
|
| 145 |
-
if invoice_date < oldest_allowed_date:
|
| 146 |
-
add_flag(
|
| 147 |
-
"date_anomaly_invoice_too_old",
|
| 148 |
-
weight=0.15,
|
| 149 |
-
field="invoice_date",
|
| 150 |
-
detail={
|
| 151 |
-
"invoice_date": str(invoice_date),
|
| 152 |
-
"oldest_allowed_date": str(oldest_allowed_date),
|
| 153 |
-
"max_invoice_age_days": max_invoice_age_days,
|
| 154 |
-
},
|
| 155 |
-
)
|
| 156 |
-
|
| 157 |
-
# 5. Service period must not be reversed.
|
| 158 |
-
if service_start_date is not None and service_end_date is not None:
|
| 159 |
-
if service_end_date < service_start_date:
|
| 160 |
-
add_flag(
|
| 161 |
-
"date_anomaly_service_period_reversed",
|
| 162 |
-
weight=0.15,
|
| 163 |
-
field="service_end_date",
|
| 164 |
-
detail={
|
| 165 |
-
"service_start_date": str(service_start_date),
|
| 166 |
-
"service_end_date": str(service_end_date),
|
| 167 |
-
},
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
# 6. Service end must not be too far after invoice date.
|
| 171 |
-
if service_end_date is not None:
|
| 172 |
-
if invoice_date is None:
|
| 173 |
-
skipped_steps.append(
|
| 174 |
-
{"step": "date_anomaly_service_end_after_invoice", "skip_reason": "no_invoice_date"}
|
| 175 |
-
)
|
| 176 |
-
else:
|
| 177 |
-
allowed_service_end = invoice_date + timedelta(days=service_end_grace_days)
|
| 178 |
-
|
| 179 |
-
if service_end_date > allowed_service_end:
|
| 180 |
-
add_flag(
|
| 181 |
-
"date_anomaly_service_end_after_invoice",
|
| 182 |
-
weight=0.15,
|
| 183 |
-
field="service_end_date",
|
| 184 |
-
detail={
|
| 185 |
-
"invoice_date": str(invoice_date),
|
| 186 |
-
"service_end_date": str(service_end_date),
|
| 187 |
-
"allowed_service_end": str(allowed_service_end),
|
| 188 |
-
"grace_days": service_end_grace_days,
|
| 189 |
-
},
|
| 190 |
-
)
|
| 191 |
-
|
| 192 |
-
# 7. Due date could not be derived.
|
| 193 |
-
if due_date is None and due_date_origin == "unknown":
|
| 194 |
-
add_flag(
|
| 195 |
-
"due_date_not_derivable",
|
| 196 |
-
weight=0.10,
|
| 197 |
-
field="due_date",
|
| 198 |
-
detail={
|
| 199 |
-
"due_date_origin": due_date_origin,
|
| 200 |
-
},
|
| 201 |
-
)
|
| 202 |
-
|
| 203 |
-
return {
|
| 204 |
-
"risk_flags": risk_flags,
|
| 205 |
-
"skipped_steps": skipped_steps,
|
| 206 |
-
"flag_codes": [flag["code"] for flag in risk_flags],
|
| 207 |
-
"checked_fields": {
|
| 208 |
-
"invoice_date": str(invoice_date) if invoice_date else None,
|
| 209 |
-
"due_date": str(due_date) if due_date else None,
|
| 210 |
-
"due_date_origin": due_date_origin,
|
| 211 |
-
"payment_terms": payment_terms,
|
| 212 |
-
"service_start_date": str(service_start_date) if service_start_date else None,
|
| 213 |
-
"service_end_date": str(service_end_date) if service_end_date else None,
|
| 214 |
-
"current_date": str(current_date),
|
| 215 |
-
"timezone": default_timezone,
|
| 216 |
-
"max_invoice_age_days": max_invoice_age_days,
|
| 217 |
-
"service_end_post_invoice_grace_days": service_end_grace_days,
|
| 218 |
-
},
|
| 219 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/Invoice Validation Functions/orchestrator.py
DELETED
|
@@ -1,282 +0,0 @@
|
|
| 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_checks")) # tax validation uses "skipped_checks"
|
| 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
DELETED
|
@@ -1,264 +0,0 @@
|
|
| 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 |
-
skipped_steps = []
|
| 176 |
-
|
| 177 |
-
if resolved_vendor_id is None:
|
| 178 |
-
skipped_steps.append(
|
| 179 |
-
{
|
| 180 |
-
"step": "payment_terms_validation",
|
| 181 |
-
"skip_reason": "vendor_unresolved",
|
| 182 |
-
}
|
| 183 |
-
)
|
| 184 |
-
return {
|
| 185 |
-
"risk_flags": risk_flags,
|
| 186 |
-
"skipped_steps": skipped_steps,
|
| 187 |
-
"result": "skipped",
|
| 188 |
-
"expected_payment_terms": None,
|
| 189 |
-
"expected_source_tier": None,
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
-
invoice_payment_terms = as_int(getattr(extracted_fields, "payment_terms", None))
|
| 193 |
-
|
| 194 |
-
if invoice_payment_terms is None:
|
| 195 |
-
skipped_steps.append(
|
| 196 |
-
{
|
| 197 |
-
"step": "payment_terms_validation",
|
| 198 |
-
"skip_reason": "invoice_payment_terms_missing",
|
| 199 |
-
}
|
| 200 |
-
)
|
| 201 |
-
|
| 202 |
-
return {
|
| 203 |
-
"risk_flags": risk_flags,
|
| 204 |
-
"skipped_steps": skipped_steps,
|
| 205 |
-
"result": "skipped",
|
| 206 |
-
"expected_payment_terms": None,
|
| 207 |
-
"expected_source_tier": None,
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
resolved = (
|
| 211 |
-
po_terms_tier()
|
| 212 |
-
or contract_terms_tier()
|
| 213 |
-
or vendor_master_terms_tier()
|
| 214 |
-
)
|
| 215 |
-
|
| 216 |
-
if resolved is None:
|
| 217 |
-
add_flag(
|
| 218 |
-
"vendor_payment_terms_unconfigured",
|
| 219 |
-
weight=0.10,
|
| 220 |
-
detail={
|
| 221 |
-
"reason": "no_po_contract_or_vendor_payment_terms",
|
| 222 |
-
},
|
| 223 |
-
)
|
| 224 |
-
|
| 225 |
-
return {
|
| 226 |
-
"risk_flags": risk_flags,
|
| 227 |
-
"skipped_steps": skipped_steps,
|
| 228 |
-
"result": "unconfigured",
|
| 229 |
-
"expected_payment_terms": None,
|
| 230 |
-
"expected_source_tier": None,
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
expected_payment_terms = resolved["expected_payment_terms"]
|
| 234 |
-
expected_source_tier = resolved["tier"]
|
| 235 |
-
|
| 236 |
-
difference = abs(invoice_payment_terms - expected_payment_terms)
|
| 237 |
-
|
| 238 |
-
if difference > tolerance_days:
|
| 239 |
-
add_flag(
|
| 240 |
-
"payment_terms_mismatch",
|
| 241 |
-
weight=0.20,
|
| 242 |
-
detail={
|
| 243 |
-
"invoice_payment_terms": invoice_payment_terms,
|
| 244 |
-
"expected_payment_terms": expected_payment_terms,
|
| 245 |
-
"difference_days": difference,
|
| 246 |
-
"tolerance_days": tolerance_days,
|
| 247 |
-
"expected_source_tier": expected_source_tier,
|
| 248 |
-
"source_id": resolved.get("source_id"),
|
| 249 |
-
},
|
| 250 |
-
)
|
| 251 |
-
|
| 252 |
-
result = "mismatch"
|
| 253 |
-
else:
|
| 254 |
-
result = "matched"
|
| 255 |
-
|
| 256 |
-
return {
|
| 257 |
-
"risk_flags": risk_flags,
|
| 258 |
-
"skipped_steps": skipped_steps,
|
| 259 |
-
"result": result,
|
| 260 |
-
"invoice_payment_terms": invoice_payment_terms,
|
| 261 |
-
"expected_payment_terms": expected_payment_terms,
|
| 262 |
-
"expected_source_tier": expected_source_tier,
|
| 263 |
-
"tolerance_days": tolerance_days,
|
| 264 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/Invoice Validation Functions/platform_configs.json
DELETED
|
@@ -1,67 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,587 +0,0 @@
|
|
| 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(check_name, reason):
|
| 110 |
-
skipped_checks.append({"check": check_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 all_line_tax_amounts_zero():
|
| 238 |
-
found_any = False
|
| 239 |
-
for line in line_items:
|
| 240 |
-
amount = as_decimal(getattr(line, "tax_amount_per_item", None))
|
| 241 |
-
if amount is None:
|
| 242 |
-
continue
|
| 243 |
-
found_any = True
|
| 244 |
-
if amount != Decimal("0"):
|
| 245 |
-
return False
|
| 246 |
-
|
| 247 |
-
summary_tax_amount = as_decimal(getattr(extracted_fields, "tax_amount", None))
|
| 248 |
-
if summary_tax_amount is not None:
|
| 249 |
-
found_any = True
|
| 250 |
-
if summary_tax_amount != Decimal("0"):
|
| 251 |
-
return False
|
| 252 |
-
|
| 253 |
-
return found_any
|
| 254 |
-
|
| 255 |
-
def sender_tax_family():
|
| 256 |
-
return getattr(extracted_fields, "sender_tax_id_inferred_family", None)
|
| 257 |
-
|
| 258 |
-
def sender_tax_valid_for_country(expected_family):
|
| 259 |
-
return (
|
| 260 |
-
getattr(extracted_fields, "sender_tax_id", None) is not None
|
| 261 |
-
and sender_tax_family() == expected_family
|
| 262 |
-
and bool(getattr(extracted_fields, "sender_tax_id_format_valid", True))
|
| 263 |
-
and bool(getattr(extracted_fields, "sender_tax_id_checksum_valid", True))
|
| 264 |
-
)
|
| 265 |
-
|
| 266 |
-
def supplier_and_recipient_are_eu():
|
| 267 |
-
return supplier_country in eu_member_states and recipient_country in eu_member_states
|
| 268 |
-
|
| 269 |
-
def eu_reverse_charge_conditions_met():
|
| 270 |
-
if not supplier_and_recipient_are_eu():
|
| 271 |
-
return False
|
| 272 |
-
if supplier_country == recipient_country:
|
| 273 |
-
return False
|
| 274 |
-
|
| 275 |
-
if vendor_exemption_status == "reverse_charge":
|
| 276 |
-
return True
|
| 277 |
-
|
| 278 |
-
supplier_family = f"VAT-{supplier_country}"
|
| 279 |
-
supplier_vat_valid = sender_tax_valid_for_country(supplier_family)
|
| 280 |
-
recipient_vat_present = bool(getattr(entity, "vat_id", None))
|
| 281 |
-
|
| 282 |
-
return (
|
| 283 |
-
supplier_vat_valid
|
| 284 |
-
and recipient_vat_present
|
| 285 |
-
and all_line_rates_zero()
|
| 286 |
-
and is_zero(getattr(extracted_fields, "tax_amount", None))
|
| 287 |
-
)
|
| 288 |
-
|
| 289 |
-
def india_jurisdiction():
|
| 290 |
-
supplier_state = getattr(vendor, "billing_state", None)
|
| 291 |
-
recipient_state = getattr(entity, "region_code", None)
|
| 292 |
-
if not supplier_state or not recipient_state:
|
| 293 |
-
return None
|
| 294 |
-
if str(supplier_state).upper() == str(recipient_state).upper():
|
| 295 |
-
return "intra_state"
|
| 296 |
-
return "inter_state"
|
| 297 |
-
|
| 298 |
-
risk_flags = []
|
| 299 |
-
advisory_flags = []
|
| 300 |
-
skipped_checks = []
|
| 301 |
-
ops_alerts = []
|
| 302 |
-
matched_tax_master_rows = {}
|
| 303 |
-
|
| 304 |
-
active_tax_master = effective_tax_master_rows()
|
| 305 |
-
|
| 306 |
-
supplier_country = country(getattr(vendor, "billing_country", None))
|
| 307 |
-
recipient_country = country(getattr(entity, "country_code", None))
|
| 308 |
-
invoice_currency = country(getattr(extracted_fields, "currency", None))
|
| 309 |
-
vendor_exemption_status = getattr(vendor, "exemption_status", None)
|
| 310 |
-
invoice_date = invoice_date_value()
|
| 311 |
-
|
| 312 |
-
if not active_tax_master:
|
| 313 |
-
ops_alerts.append(
|
| 314 |
-
{
|
| 315 |
-
"code": "alert.tax_master_unconfigured",
|
| 316 |
-
"tenant_id": document.tenant_id,
|
| 317 |
-
}
|
| 318 |
-
)
|
| 319 |
-
return {
|
| 320 |
-
"risk_flags": risk_flags,
|
| 321 |
-
"advisory_flags": advisory_flags,
|
| 322 |
-
"skipped_checks": [
|
| 323 |
-
{"check": "all_tax_checks", "skip_reason": "tax_master_unconfigured"}
|
| 324 |
-
],
|
| 325 |
-
"ops_alerts": ops_alerts,
|
| 326 |
-
"matched_tax_master_rows": matched_tax_master_rows,
|
| 327 |
-
}
|
| 328 |
-
|
| 329 |
-
# Check 1 and Check 2 both skip if supplier country is missing.
|
| 330 |
-
if not supplier_country:
|
| 331 |
-
add_skip("rate_exists_in_master", "supplier_country_missing")
|
| 332 |
-
add_skip("tax_amount_matches_taxable_base", "supplier_country_missing")
|
| 333 |
-
else:
|
| 334 |
-
unique_rates = set()
|
| 335 |
-
|
| 336 |
-
# Gather unique non-null, non-zero line rates
|
| 337 |
-
for line in line_items:
|
| 338 |
-
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 339 |
-
if rate is not None and rate != Decimal("0"):
|
| 340 |
-
unique_rates.add(
|
| 341 |
-
(
|
| 342 |
-
rate,
|
| 343 |
-
getattr(line, "region_code_hint", None),
|
| 344 |
-
getattr(line, "line_number", None),
|
| 345 |
-
)
|
| 346 |
-
)
|
| 347 |
-
|
| 348 |
-
# Summary tax rate is checked separately
|
| 349 |
-
summary_rate = as_decimal(getattr(extracted_fields, "tax_rate", None))
|
| 350 |
-
if summary_rate is not None and summary_rate != Decimal("0"):
|
| 351 |
-
unique_rates.add((summary_rate, None, "summary"))
|
| 352 |
-
|
| 353 |
-
for rate, region_code, source in unique_rates:
|
| 354 |
-
# India aggregate rate matching is handled entirely in Check 6.
|
| 355 |
-
if supplier_country == "IN":
|
| 356 |
-
continue
|
| 357 |
-
|
| 358 |
-
# US state tax needs a region hint
|
| 359 |
-
if supplier_country == "US" and not region_code:
|
| 360 |
-
add_advisory("us_region_unresolved", detail={"source": source})
|
| 361 |
-
continue
|
| 362 |
-
|
| 363 |
-
allowed_tax_types = None
|
| 364 |
-
if supplier_country == "GB":
|
| 365 |
-
allowed_tax_types = {"VAT"}
|
| 366 |
-
if supplier_country == "IN":
|
| 367 |
-
allowed_tax_types = {"CGST", "SGST", "IGST", "UTGST"}
|
| 368 |
-
if supplier_country == "US":
|
| 369 |
-
allowed_tax_types = None
|
| 370 |
-
|
| 371 |
-
reduced_rate_allowed = vendor_exemption_status == "reduced_rate"
|
| 372 |
-
|
| 373 |
-
matches = find_tax_master_rate(
|
| 374 |
-
rate=rate,
|
| 375 |
-
country_code=supplier_country,
|
| 376 |
-
region_code=region_code,
|
| 377 |
-
allowed_tax_types=allowed_tax_types,
|
| 378 |
-
reduced_rate_allowed=reduced_rate_allowed,
|
| 379 |
-
)
|
| 380 |
-
|
| 381 |
-
if matches:
|
| 382 |
-
matched_tax_master_rows[source] = [getattr(row, "id", None) for row in matches]
|
| 383 |
-
else:
|
| 384 |
-
add_flag(
|
| 385 |
-
"tax_rate_not_in_master",
|
| 386 |
-
weight=Decimal("0.35"),
|
| 387 |
-
detail={
|
| 388 |
-
"rate": str(rate),
|
| 389 |
-
"country": supplier_country,
|
| 390 |
-
"region_code": region_code,
|
| 391 |
-
"source": source,
|
| 392 |
-
},
|
| 393 |
-
)
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
# Check 2: Amount matches taxable base.
|
| 397 |
-
if supplier_country:
|
| 398 |
-
tolerance = currency_tolerance()
|
| 399 |
-
if tolerance is None:
|
| 400 |
-
add_skip("tax_amount_matches_taxable_base", "currency_minor_unit_unknown")
|
| 401 |
-
else:
|
| 402 |
-
for line in line_items:
|
| 403 |
-
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 404 |
-
amount = as_decimal(getattr(line, "amount", None))
|
| 405 |
-
actual_tax = as_decimal(getattr(line, "tax_amount_per_item", None))
|
| 406 |
-
|
| 407 |
-
if rate is None or amount is None or actual_tax is None:
|
| 408 |
-
continue
|
| 409 |
-
|
| 410 |
-
discount = as_decimal(getattr(line, "discount_amount_per_item", None)) or Decimal("0")
|
| 411 |
-
taxable_base = amount - discount
|
| 412 |
-
expected_tax = taxable_base * (rate / Decimal("100"))
|
| 413 |
-
|
| 414 |
-
if abs(expected_tax - actual_tax) > tolerance:
|
| 415 |
-
add_flag(
|
| 416 |
-
"tax_amount_mismatch",
|
| 417 |
-
weight=Decimal("0.35"),
|
| 418 |
-
detail={
|
| 419 |
-
"line_number": getattr(line, "line_number", None),
|
| 420 |
-
"expected_tax": str(expected_tax.quantize(tolerance, rounding=ROUND_HALF_UP)),
|
| 421 |
-
"actual_tax": str(actual_tax),
|
| 422 |
-
"currency": invoice_currency,
|
| 423 |
-
},
|
| 424 |
-
)
|
| 425 |
-
|
| 426 |
-
# §8.9: vendor_tax_exempt advisory fires when all extracted line rates are zero.
|
| 427 |
-
if vendor_exemption_status == "exempt" and all_line_rates_zero():
|
| 428 |
-
add_advisory("vendor_tax_exempt")
|
| 429 |
-
|
| 430 |
-
# Check 3: Cross-country routing.
|
| 431 |
-
summary_tax_amount = as_decimal(getattr(extracted_fields, "tax_amount", None))
|
| 432 |
-
|
| 433 |
-
if supplier_country is None or summary_tax_amount is None or summary_tax_amount == Decimal("0"):
|
| 434 |
-
add_skip("cross_country_routing", "supplier_country_or_tax_amount_missing_or_zero")
|
| 435 |
-
elif recipient_country is None:
|
| 436 |
-
add_skip("cross_country_routing", "recipient_country_missing")
|
| 437 |
-
elif supplier_country != recipient_country:
|
| 438 |
-
add_flag(
|
| 439 |
-
"cross_country_tax",
|
| 440 |
-
weight=Decimal("0.30"),
|
| 441 |
-
detail={
|
| 442 |
-
"supplier_country": supplier_country,
|
| 443 |
-
"recipient_country": recipient_country,
|
| 444 |
-
},
|
| 445 |
-
)
|
| 446 |
-
|
| 447 |
-
# Check 4: United States.
|
| 448 |
-
if supplier_country == "US" or recipient_country == "US":
|
| 449 |
-
if supplier_country == "US" and summary_tax_amount is not None and summary_tax_amount != Decimal("0"):
|
| 450 |
-
add_flag(
|
| 451 |
-
"us_sales_tax",
|
| 452 |
-
weight=Decimal("0.20"),
|
| 453 |
-
detail={"tax_amount": str(summary_tax_amount)},
|
| 454 |
-
)
|
| 455 |
-
|
| 456 |
-
if (
|
| 457 |
-
recipient_country == "US"
|
| 458 |
-
and supplier_country != recipient_country
|
| 459 |
-
and all_line_rates_zero()
|
| 460 |
-
):
|
| 461 |
-
add_flag(
|
| 462 |
-
"us_use_tax_possibly_owed",
|
| 463 |
-
weight=Decimal("0.15"),
|
| 464 |
-
)
|
| 465 |
-
|
| 466 |
-
# Check 5: United Kingdom.
|
| 467 |
-
if supplier_country == "GB" or recipient_country == "GB":
|
| 468 |
-
for line in line_items:
|
| 469 |
-
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 470 |
-
tax_amount = as_decimal(getattr(line, "tax_amount_per_item", None))
|
| 471 |
-
|
| 472 |
-
if rate == Decimal("0"):
|
| 473 |
-
gb_matches = find_tax_master_rate(
|
| 474 |
-
rate=rate,
|
| 475 |
-
country_code="GB",
|
| 476 |
-
region_code=getattr(line, "region_code_hint", None),
|
| 477 |
-
allowed_tax_types={"zero_rated", "exempt", "VAT"},
|
| 478 |
-
reduced_rate_allowed=False,
|
| 479 |
-
)
|
| 480 |
-
|
| 481 |
-
if any(getattr(row, "tax_type", None) == "zero_rated" for row in gb_matches):
|
| 482 |
-
add_advisory("uk_zero_rated", detail={"line_number": getattr(line, "line_number", None)})
|
| 483 |
-
elif tax_amount == Decimal("0") and any(
|
| 484 |
-
getattr(row, "tax_type", None) == "exempt" for row in gb_matches
|
| 485 |
-
):
|
| 486 |
-
add_advisory("uk_exempt", detail={"line_number": getattr(line, "line_number", None)})
|
| 487 |
-
elif not (
|
| 488 |
-
vendor_exemption_status == "reverse_charge"
|
| 489 |
-
and supplier_and_recipient_are_eu()
|
| 490 |
-
and supplier_country != recipient_country
|
| 491 |
-
) and not (
|
| 492 |
-
vendor_exemption_status == "exempt"
|
| 493 |
-
and all_line_rates_zero()
|
| 494 |
-
):
|
| 495 |
-
add_flag(
|
| 496 |
-
"tax_rate_not_in_master",
|
| 497 |
-
weight=Decimal("0.35"),
|
| 498 |
-
detail={
|
| 499 |
-
"rate": "0",
|
| 500 |
-
"country": "GB",
|
| 501 |
-
"source": getattr(line, "line_number", None),
|
| 502 |
-
},
|
| 503 |
-
)
|
| 504 |
-
|
| 505 |
-
if (
|
| 506 |
-
supplier_country == "GB"
|
| 507 |
-
and recipient_country == "GB"
|
| 508 |
-
and summary_tax_amount is not None
|
| 509 |
-
and summary_tax_amount != Decimal("0")
|
| 510 |
-
and not sender_tax_valid_for_country("VAT-GB")
|
| 511 |
-
):
|
| 512 |
-
add_flag(
|
| 513 |
-
"missing_tax_id_for_local_tax",
|
| 514 |
-
weight=Decimal("0.25"),
|
| 515 |
-
detail={"expected_family": "VAT-GB"},
|
| 516 |
-
)
|
| 517 |
-
|
| 518 |
-
# Check 6: India GST.
|
| 519 |
-
if supplier_country == "IN" or recipient_country == "IN":
|
| 520 |
-
jurisdiction = india_jurisdiction()
|
| 521 |
-
if jurisdiction is None:
|
| 522 |
-
add_advisory("in_gst_jurisdiction_unresolved")
|
| 523 |
-
else:
|
| 524 |
-
add_advisory("in_gst_jurisdiction_inferred", detail={"jurisdiction": jurisdiction})
|
| 525 |
-
|
| 526 |
-
# Sender tax ID must look like GSTIN-IN or PAN-IN
|
| 527 |
-
if sender_tax_family() not in {"GSTIN-IN", "PAN-IN"}:
|
| 528 |
-
add_flag(
|
| 529 |
-
"missing_tax_id_for_local_tax",
|
| 530 |
-
weight=Decimal("0.25"),
|
| 531 |
-
detail={"expected_family": "GSTIN-IN or PAN-IN"},
|
| 532 |
-
)
|
| 533 |
-
|
| 534 |
-
for line in line_items:
|
| 535 |
-
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 536 |
-
if rate is None or rate == Decimal("0"):
|
| 537 |
-
continue
|
| 538 |
-
|
| 539 |
-
region_code = None
|
| 540 |
-
if jurisdiction == "intra_state":
|
| 541 |
-
region_code = getattr(vendor, "billing_state", None) or getattr(entity, "region_code", None)
|
| 542 |
-
|
| 543 |
-
matches = find_india_gst_aggregate_rate(rate, region_code, jurisdiction)
|
| 544 |
-
|
| 545 |
-
if matches:
|
| 546 |
-
matched_tax_master_rows[getattr(line, "line_number", None)] = [
|
| 547 |
-
getattr(r, "id", None) for r in matches
|
| 548 |
-
]
|
| 549 |
-
else:
|
| 550 |
-
add_flag(
|
| 551 |
-
"tax_rate_not_in_master",
|
| 552 |
-
weight=Decimal("0.35"),
|
| 553 |
-
detail={
|
| 554 |
-
"rate": str(rate),
|
| 555 |
-
"country": "IN",
|
| 556 |
-
"region_code": region_code,
|
| 557 |
-
"source": getattr(line, "line_number", None),
|
| 558 |
-
},
|
| 559 |
-
)
|
| 560 |
-
|
| 561 |
-
# Check 7: EU reverse charge.
|
| 562 |
-
# If conditions are met, add advisory and retract cross_country_tax raised by Check 3.
|
| 563 |
-
if supplier_and_recipient_are_eu() and supplier_country != recipient_country:
|
| 564 |
-
if eu_reverse_charge_conditions_met():
|
| 565 |
-
add_advisory("eu_reverse_charge")
|
| 566 |
-
risk_flags[:] = [f for f in risk_flags if f["code"] != "cross_country_tax"]
|
| 567 |
-
|
| 568 |
-
# Check 8: Withholding tax advisory.
|
| 569 |
-
if vendor_exemption_status == "withholding_applicable":
|
| 570 |
-
add_advisory("vendor_withholding_applicable")
|
| 571 |
-
|
| 572 |
-
for line in line_items:
|
| 573 |
-
tax_master_id = getattr(line, "tax_master_id", None)
|
| 574 |
-
if not tax_master_id:
|
| 575 |
-
continue
|
| 576 |
-
for row in active_tax_master:
|
| 577 |
-
if getattr(row, "id", None) == tax_master_id and getattr(row, "tax_type", None) == "withholding":
|
| 578 |
-
add_advisory("vendor_withholding_applicable")
|
| 579 |
-
break
|
| 580 |
-
|
| 581 |
-
return {
|
| 582 |
-
"risk_flags": risk_flags,
|
| 583 |
-
"advisory_flags": advisory_flags,
|
| 584 |
-
"skipped_checks": skipped_checks,
|
| 585 |
-
"ops_alerts": ops_alerts,
|
| 586 |
-
"matched_tax_master_rows": matched_tax_master_rows,
|
| 587 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|