Bhuvi13 commited on
Commit
4eb7772
·
verified ·
1 Parent(s): 1bfb2b7

Delete src/Invoice Validation Functions

Browse files
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
- }