Bhuvi13 commited on
Commit
6f0c329
·
verified ·
1 Parent(s): 3357535

Upload 20 files

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