Bhuvi13 commited on
Commit
c253a3b
·
verified ·
1 Parent(s): c02b6e1

Upload 15 files

Browse files
src/Invoice Validation Functions/Bank validation.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from datetime import date, datetime
3
+ from hashlib import sha256
4
+ from unicodedata import normalize
5
+ from decimal import Decimal
6
+
7
+ from rapidfuzz import fuzz
8
+
9
+
10
+ def run_bank_validation(
11
+ *,
12
+ document,
13
+ extracted_fields,
14
+ vendor_bank_accounts,
15
+ resolved_vendor_id,
16
+ platform_configs=None,
17
+ name_match_fn=None,
18
+ ):
19
+ """
20
+ Bank validation logic based on Cat 2 section 7.4.
21
+
22
+ Expected document fields:
23
+ document.id
24
+ document.tenant_id
25
+
26
+ Expected extracted_fields fields:
27
+ invoice_date
28
+ currency
29
+ bank_iban
30
+ bank_acc_no
31
+ bank_swift
32
+ bank_acc_name
33
+
34
+ Expected vendor_bank_accounts row fields:
35
+ tenant_id
36
+ vendor_id
37
+ iban_canonical
38
+ acc_no_canonical
39
+ swift_bic
40
+ bank_acc_name
41
+ currency_code
42
+ bank_country
43
+ is_primary
44
+ effective_from
45
+ effective_to
46
+ deleted_at
47
+
48
+ name_match_fn:
49
+ Optional function for bank account name comparison.
50
+ Should return something like:
51
+ {"decision": "matched" | "suggestion" | "unmatched"}
52
+ """
53
+
54
+ platform_configs = platform_configs or {}
55
+
56
+ iban_checksum_required = platform_configs.get(
57
+ "validation.iban_checksum_required",
58
+ True,
59
+ )
60
+
61
+ iban_accno_exempt_countries = set(
62
+ platform_configs.get(
63
+ "validation.iban_accno_substring_exempt_countries",
64
+ ["LC", "MT"],
65
+ )
66
+ )
67
+
68
+ high_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_high_threshold", "0.90")))
69
+ medium_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_medium_threshold", "0.75")))
70
+ suggestion_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_suggestion_threshold", "0.60")))
71
+
72
+ def hash_prefix(value, length=8):
73
+ if not value:
74
+ return None
75
+ return sha256(str(value).encode("utf-8")).hexdigest()[:length]
76
+
77
+ def canonicalize_text(value):
78
+ if value is None:
79
+ return None
80
+ value = normalize("NFKC", str(value))
81
+ value = value.strip().upper()
82
+ return value or None
83
+
84
+ def canonicalize_iban(value):
85
+ if value is None:
86
+ return None
87
+ value = normalize("NFKC", str(value))
88
+ value = re.sub(r"[\s\-\.,/\\:;_]+", "", value)
89
+ value = value.upper()
90
+ return value or None
91
+
92
+ def canonicalize_acc_no(value):
93
+ if value is None:
94
+ return None
95
+ value = normalize("NFKC", str(value))
96
+ value = re.sub(r"[\s\-\.,/\\:;_]+", "", value)
97
+ value = value.upper()
98
+ return value or None
99
+
100
+ def canonicalize_swift(value):
101
+ if value is None:
102
+ return None
103
+ value = normalize("NFKC", str(value))
104
+ value = re.sub(r"\s+", "", value)
105
+ value = value.upper()
106
+ return value or None
107
+
108
+ def iban_country(iban):
109
+ if not iban or len(iban) < 2:
110
+ return None
111
+ return iban[:2]
112
+
113
+ def iban_checksum_mod97_valid(iban):
114
+ """
115
+ Standard IBAN mod-97 validation.
116
+
117
+ Move first 4 chars to end.
118
+ Replace letters A=10 ... Z=35.
119
+ Result mod 97 must equal 1.
120
+ """
121
+ if not iban or len(iban) < 4:
122
+ return False
123
+
124
+ rearranged = iban[4:] + iban[:4]
125
+ numeric = ""
126
+
127
+ for char in rearranged:
128
+ if char.isdigit():
129
+ numeric += char
130
+ elif "A" <= char <= "Z":
131
+ numeric += str(ord(char) - ord("A") + 10)
132
+ else:
133
+ return False
134
+
135
+ remainder = 0
136
+ for digit in numeric:
137
+ remainder = (remainder * 10 + int(digit)) % 97
138
+
139
+ return remainder == 1
140
+
141
+ def iban_acc_no_consistent(iban, acc_no):
142
+ """
143
+ Basic consistency check:
144
+ remove country + check digits from IBAN,
145
+ then check whether account number appears inside BBAN.
146
+
147
+ Leading zeros are ignored for comparison.
148
+ """
149
+ if not iban or not acc_no:
150
+ return True
151
+
152
+ country = iban_country(iban)
153
+
154
+ if country in iban_accno_exempt_countries:
155
+ return True
156
+
157
+ bban = iban[4:]
158
+ stripped_acc_no = acc_no.lstrip("0") or acc_no
159
+
160
+ return stripped_acc_no in bban
161
+
162
+ def is_effective(row, invoice_date):
163
+ if getattr(row, "deleted_at", None):
164
+ return False
165
+
166
+ if invoice_date is None:
167
+ return True
168
+
169
+ effective_from = getattr(row, "effective_from", None)
170
+ effective_to = getattr(row, "effective_to", None)
171
+
172
+ if effective_from is not None and invoice_date < effective_from:
173
+ return False
174
+
175
+ if effective_to and invoice_date >= effective_to:
176
+ return False
177
+
178
+ return True
179
+
180
+ def default_name_match(invoice_name, master_name):
181
+ if not invoice_name or not master_name:
182
+ return {"decision": "unmatched"}
183
+
184
+ score = Decimal(str(fuzz.WRatio(invoice_name, master_name) / 100)).quantize(
185
+ Decimal("0.0001")
186
+ )
187
+
188
+ if score >= high_threshold:
189
+ return {"decision": "matched", "score_tier": "high"}
190
+ if score >= medium_threshold:
191
+ return {"decision": "matched", "score_tier": "medium"}
192
+ if score >= suggestion_threshold:
193
+ return {"decision": "suggestion", "score_tier": "low"}
194
+ return {"decision": "unmatched", "score_tier": "low"}
195
+
196
+ def bank_account_name_matches(invoice_name, master_name):
197
+ matcher = name_match_fn or default_name_match
198
+
199
+ result = matcher(
200
+ query_name=invoice_name,
201
+ candidate_pool=[master_name],
202
+ context={"purpose": "bank_acc_name_check"},
203
+ ) if name_match_fn else matcher(invoice_name, master_name)
204
+
205
+ return result.get("decision") == "matched"
206
+
207
+ def add_flag(code, *, field=None, weight=None, match_result="failed", invoice_value=None,
208
+ master_value=None, currency_invoice=None, currency_master=None,
209
+ primary_or_non_primary=None):
210
+ flag = {
211
+ "code": code,
212
+ "field": field,
213
+ "severity": "advisory",
214
+ }
215
+
216
+ if weight is not None:
217
+ flag["weight"] = weight
218
+
219
+ risk_flags.append(flag)
220
+
221
+ audit_entries.append(
222
+ {
223
+ "field": field,
224
+ "match_result": match_result,
225
+ "invoice_hash_prefix": hash_prefix(invoice_value),
226
+ "master_hash_prefix": hash_prefix(master_value),
227
+ "currency_invoice": currency_invoice,
228
+ "currency_master": currency_master,
229
+ "primary_or_non_primary": primary_or_non_primary,
230
+ }
231
+ )
232
+
233
+ risk_flags = []
234
+ skipped_steps = []
235
+ audit_entries = []
236
+
237
+ if resolved_vendor_id is None:
238
+ skipped_steps.append(
239
+ {
240
+ "step": "bank_validation",
241
+ "skip_reason": "vendor_unresolved",
242
+ }
243
+ )
244
+
245
+ return {
246
+ "risk_flags": risk_flags,
247
+ "skipped_steps": skipped_steps,
248
+ "audit_entries": audit_entries,
249
+ "match_result": "skipped",
250
+ }
251
+
252
+ invoice_date = getattr(extracted_fields, "invoice_date", None)
253
+
254
+ if isinstance(invoice_date, datetime):
255
+ invoice_date = invoice_date.date()
256
+
257
+
258
+ invoice_iban = canonicalize_iban(getattr(extracted_fields, "bank_iban", None))
259
+ invoice_acc_no = canonicalize_acc_no(getattr(extracted_fields, "bank_acc_no", None))
260
+ invoice_swift = canonicalize_swift(getattr(extracted_fields, "bank_swift", None))
261
+ invoice_bank_acc_name = getattr(extracted_fields, "bank_acc_name", None)
262
+ invoice_currency = canonicalize_text(getattr(extracted_fields, "currency", None))
263
+
264
+ # 1. Invoice-side pre-format checks.
265
+ if invoice_iban and iban_checksum_required:
266
+ if not iban_checksum_mod97_valid(invoice_iban):
267
+ add_flag(
268
+ "bank_iban_invalid_checksum",
269
+ field="bank_iban",
270
+ weight=Decimal("0.35"),
271
+ invoice_value=invoice_iban,
272
+ )
273
+
274
+ if invoice_iban and invoice_acc_no:
275
+ if not iban_acc_no_consistent(invoice_iban, invoice_acc_no):
276
+ add_flag(
277
+ "bank_iban_accno_inconsistent",
278
+ field="bank_iban/bank_acc_no",
279
+ weight=Decimal("0.35"),
280
+ invoice_value=f"{invoice_iban}|{invoice_acc_no}",
281
+ )
282
+
283
+ # 2. Build same-tenant, same-vendor, active candidate pool.
284
+ candidate_pool = [
285
+ row
286
+ for row in vendor_bank_accounts
287
+ if getattr(row, "tenant_id", None) == document.tenant_id
288
+ and getattr(row, "vendor_id", None) == resolved_vendor_id
289
+ and not getattr(row, "deleted_at", None)
290
+ and is_effective(row, invoice_date)
291
+ ]
292
+
293
+ candidate_pool.sort(
294
+ key=lambda row: (
295
+ bool(getattr(row, "is_primary", False)),
296
+ getattr(row, "effective_from", None) or date.max,
297
+ ),
298
+ reverse=True,
299
+ )
300
+
301
+ primary_rows = [
302
+ row for row in candidate_pool
303
+ if bool(getattr(row, "is_primary", False))
304
+ ]
305
+
306
+ non_primary_rows = [
307
+ row for row in candidate_pool
308
+ if not bool(getattr(row, "is_primary", False))
309
+ ]
310
+
311
+ primary = primary_rows[0] if primary_rows else None
312
+
313
+ invoice_has_any_bank_detail = any(
314
+ [
315
+ invoice_iban,
316
+ invoice_acc_no,
317
+ invoice_swift,
318
+ invoice_bank_acc_name,
319
+ ]
320
+ )
321
+
322
+ if invoice_has_any_bank_detail and not candidate_pool:
323
+ add_flag(
324
+ "new_bank_details_no_master",
325
+ field="bank_details",
326
+ invoice_value="bank_details_present",
327
+ )
328
+
329
+ return {
330
+ "risk_flags": risk_flags,
331
+ "skipped_steps": skipped_steps,
332
+ "audit_entries": audit_entries,
333
+ "match_result": "no_master_bank_details",
334
+ }
335
+
336
+ # 3. Compare against primary account.
337
+ if primary:
338
+ primary_match = True
339
+
340
+ master_iban = getattr(primary, "iban_canonical", None)
341
+ master_acc_no = getattr(primary, "acc_no_canonical", None)
342
+ master_swift = canonicalize_swift(getattr(primary, "swift_bic", None))
343
+ master_bank_acc_name = getattr(primary, "bank_acc_name", None)
344
+ master_currency = canonicalize_text(getattr(primary, "currency_code", None))
345
+
346
+ if invoice_iban:
347
+ if not master_iban:
348
+ primary_match = False
349
+ add_flag(
350
+ "new_bank_field_type_no_master",
351
+ field="bank_iban",
352
+ invoice_value=invoice_iban,
353
+ primary_or_non_primary="primary",
354
+ )
355
+ elif invoice_iban != master_iban:
356
+ primary_match = False
357
+ add_flag(
358
+ "bank_iban_mismatch",
359
+ field="bank_iban",
360
+ invoice_value=invoice_iban,
361
+ master_value=master_iban,
362
+ primary_or_non_primary="primary",
363
+ )
364
+
365
+ if invoice_acc_no and master_acc_no and invoice_acc_no != master_acc_no:
366
+ primary_match = False
367
+ add_flag(
368
+ "bank_acc_no_mismatch",
369
+ field="bank_acc_no",
370
+ invoice_value=invoice_acc_no,
371
+ master_value=master_acc_no,
372
+ primary_or_non_primary="primary",
373
+ )
374
+
375
+ if invoice_swift and master_swift and invoice_swift != master_swift:
376
+ primary_match = False
377
+ add_flag(
378
+ "bank_swift_mismatch",
379
+ field="bank_swift",
380
+ invoice_value=invoice_swift,
381
+ master_value=master_swift,
382
+ primary_or_non_primary="primary",
383
+ )
384
+
385
+ if invoice_bank_acc_name and master_bank_acc_name:
386
+ if not bank_account_name_matches(invoice_bank_acc_name, master_bank_acc_name):
387
+ primary_match = False
388
+ add_flag(
389
+ "bank_acc_name_mismatch",
390
+ field="bank_acc_name",
391
+ weight=Decimal("0.30"),
392
+ invoice_value=invoice_bank_acc_name,
393
+ master_value=master_bank_acc_name,
394
+ primary_or_non_primary="primary",
395
+ )
396
+
397
+ if invoice_currency and master_currency and invoice_currency != master_currency:
398
+ primary_match = False
399
+ add_flag(
400
+ "bank_currency_mismatch",
401
+ field="currency",
402
+ weight=Decimal("0.20"),
403
+ currency_invoice=invoice_currency,
404
+ currency_master=master_currency,
405
+ primary_or_non_primary="primary",
406
+ )
407
+
408
+ match_result = "primary_match" if primary_match else "primary_mismatch"
409
+
410
+ else:
411
+ match_result = "no_primary_bank_account"
412
+
413
+ secondary_match = None
414
+ for row in non_primary_rows:
415
+ if invoice_iban and getattr(row, "iban_canonical", None) == invoice_iban:
416
+ secondary_match = row
417
+ break
418
+
419
+ if secondary_match:
420
+ add_flag(
421
+ "bank_non_primary_match",
422
+ field="bank_iban",
423
+ weight=Decimal("0.25"),
424
+ match_result="matched_non_primary",
425
+ invoice_value=invoice_iban,
426
+ master_value=getattr(secondary_match, "iban_canonical", None),
427
+ primary_or_non_primary="non_primary",
428
+ )
429
+
430
+ return {
431
+ "risk_flags": risk_flags,
432
+ "skipped_steps": skipped_steps,
433
+ "audit_entries": audit_entries,
434
+ "match_result": match_result,
435
+ "candidate_count": len(candidate_pool),
436
+ "primary_candidate_count": len(primary_rows),
437
+ "non_primary_candidate_count": len(non_primary_rows),
438
+ }
src/Invoice Validation Functions/Tax - ID validation.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from datetime import datetime, date, timezone
3
+ from hashlib import sha256
4
+ from unicodedata import normalize
5
+
6
+
7
+ def _h_gb_vat_mod97(value, **_kw):
8
+ # UK VAT mod-97. Tries Method A then Method B (new-style) per HMRC spec.
9
+ # Value: 9 or 12 digits, GB prefix already stripped.
10
+ digits = re.sub(r"\D", "", value)
11
+ if len(digits) not in (9, 12):
12
+ return False
13
+ d = [int(c) for c in digits[:9]]
14
+ weights = [8, 7, 6, 5, 4, 3, 2]
15
+ s = sum(weights[i] * d[i] for i in range(7))
16
+ check = d[7] * 10 + d[8]
17
+ return (s - check) % 97 == 0 or (s + 55 - check) % 97 == 0
18
+
19
+
20
+ def _h_de_vat_mod11(value, **_kw):
21
+ # German VAT recursive mod-11 check digit.
22
+ # Value: 9 digits, DE prefix already stripped.
23
+ digits = re.sub(r"\D", "", value)
24
+ if len(digits) != 9:
25
+ return False
26
+ product = 10
27
+ for c in digits[:8]:
28
+ s = (int(c) + product) % 10
29
+ if s == 0:
30
+ s = 10
31
+ product = (2 * s) % 11
32
+ check = 11 - product
33
+ if check == 10:
34
+ check = 0
35
+ return int(digits[8]) == check
36
+
37
+
38
+ def _h_fr_vat_mod97(value, **_kw):
39
+ # French TVA key check.
40
+ # Value: 11 chars (FR prefix stripped): 2-char key + 9-digit SIREN.
41
+ # Numeric keys use the standard formula; alphanumeric keys use base-36 encoding.
42
+ if len(value) != 11:
43
+ return False
44
+ siren_str = value[2:]
45
+ if not siren_str.isdigit():
46
+ return False
47
+ siren = int(siren_str)
48
+ key = value[:2]
49
+ if key.isdigit():
50
+ return int(key) == (12 + 3 * (siren % 97)) % 97
51
+ def _char_val(c):
52
+ return int(c) if c.isdigit() else ord(c.upper()) - 55
53
+ k = _char_val(key[0]) * 36 + _char_val(key[1])
54
+ return k == (24 + 25 * (siren % 97)) % 97
55
+
56
+
57
+ def _h_it_vat_luhn(value, **_kw):
58
+ # Italian Partita IVA Luhn-style check digit.
59
+ # Value: 11 digits, IT prefix already stripped.
60
+ digits = re.sub(r"\D", "", value)
61
+ if len(digits) != 11:
62
+ return False
63
+ odd_sum = sum(int(digits[i]) for i in range(0, 10, 2))
64
+ even_sum = 0
65
+ for i in range(1, 10, 2):
66
+ d = int(digits[i]) * 2
67
+ if d > 9:
68
+ d -= 9
69
+ even_sum += d
70
+ total = odd_sum + even_sum
71
+ return int(digits[10]) == (10 - total % 10) % 10
72
+
73
+
74
+ def _h_es_nif(value, **_kw):
75
+ # Spanish NIF/CIF check character.
76
+ # Value: 9 chars (ES prefix stripped): [A-Z0-9][0-9]{7}[A-Z0-9].
77
+ if len(value) != 9:
78
+ return False
79
+ middle = value[1:8]
80
+ if not middle.isdigit():
81
+ return False
82
+ total = 0
83
+ for i, c in enumerate(middle):
84
+ d = int(c)
85
+ if i % 2 == 0:
86
+ d *= 2
87
+ if d > 9:
88
+ d -= 9
89
+ total += d
90
+ control = (10 - total % 10) % 10
91
+ check = value[8]
92
+ if check.isdigit():
93
+ return int(check) == control
94
+ return check.upper() == "JABCDEFGHI"[control]
95
+
96
+
97
+ def _h_gstin_crc(value, **_kw):
98
+ # Indian GSTIN Luhn-mod36 check digit (official GSTN algorithm).
99
+ # Value: 15-char GSTIN (no prefix removed — GSTIN has no country prefix in its pattern).
100
+ if len(value) != 15:
101
+ return False
102
+ charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
103
+ total = 0
104
+ factor = 2
105
+ for c in value[:14]:
106
+ if c not in charset:
107
+ return False
108
+ pos = charset.index(c)
109
+ product = factor * pos
110
+ total += product // 36 + product % 36
111
+ factor = 3 - factor
112
+ check_pos = (36 - total % 36) % 36
113
+ return value[14] == charset[check_pos]
114
+
115
+
116
+ def _h_abn_mod89(value, **_kw):
117
+ # Australian Business Number mod-89 weighted check.
118
+ # Value: 11 digits (no prefix removed).
119
+ digits = re.sub(r"\D", "", value)
120
+ if len(digits) != 11:
121
+ return False
122
+ weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
123
+ d = [int(c) for c in digits]
124
+ d[0] -= 1
125
+ return sum(d[i] * weights[i] for i in range(11)) % 89 == 0
126
+
127
+
128
+ def _h_bn_luhn(value, **_kw):
129
+ # Canadian Business Number Luhn check on the 9-digit registration number.
130
+ # The optional 6-char program account suffix (if present) is ignored.
131
+ nine = value[:9]
132
+ if len(nine) != 9 or not nine.isdigit():
133
+ return False
134
+ total = 0
135
+ for i in range(9):
136
+ d = int(nine[i])
137
+ if i % 2 == 1:
138
+ d *= 2
139
+ if d > 9:
140
+ d -= 9
141
+ total += d
142
+ return total % 10 == 0
143
+
144
+
145
+ def _h_sg_nric_fin(value, **_kw):
146
+ # Singapore NRIC/FIN check character.
147
+ # Value: 9 chars — [STFG][0-9]{7}[A-Z] (no prefix removed).
148
+ value = value.upper()
149
+ if len(value) != 9 or value[0] not in "STFG" or not value[1:8].isdigit():
150
+ return False
151
+ weights = [2, 7, 6, 5, 4, 3, 2]
152
+ total = sum(int(value[i + 1]) * weights[i] for i in range(7))
153
+ if value[0] in "TG":
154
+ total += 4
155
+ check_chars = "JZIHGFEDCBA" if value[0] in "ST" else "XWUTRQPNMLK"
156
+ return value[8] == check_chars[total % 11]
157
+
158
+
159
+ _BUILTIN_CHECKSUM_HANDLERS = {
160
+ "gb_vat_mod97": _h_gb_vat_mod97,
161
+ "de_vat_mod11": _h_de_vat_mod11,
162
+ "fr_vat_mod97": _h_fr_vat_mod97,
163
+ "it_vat_luhn": _h_it_vat_luhn,
164
+ "es_nif": _h_es_nif,
165
+ "gstin_crc": _h_gstin_crc,
166
+ "abn_mod89": _h_abn_mod89,
167
+ "bn_luhn": _h_bn_luhn,
168
+ "sg_nric_fin": _h_sg_nric_fin,
169
+ }
170
+
171
+
172
+ def run_tax_id_validation(
173
+ *,
174
+ document,
175
+ extracted_fields,
176
+ vendor,
177
+ vendor_tax_ids,
178
+ resolved_vendor_id,
179
+ platform_configs=None,
180
+ ):
181
+ """
182
+ Runs Tax ID Validation after vendor resolution.
183
+
184
+ Expected document fields:
185
+ document.id
186
+ document.tenant_id
187
+
188
+ Expected extracted_fields fields:
189
+ sender_tax_id
190
+ invoice_date
191
+
192
+ Expected vendor fields:
193
+ vendor.id
194
+ vendor.billing_country
195
+ vendor.tax_registration_number
196
+
197
+ Expected vendor_tax_ids row fields:
198
+ tenant_id
199
+ vendor_id
200
+ tax_id_canonical
201
+ tax_id_type
202
+ verified_at
203
+ deleted_at
204
+ effective_from
205
+ effective_to
206
+ """
207
+
208
+ platform_configs = platform_configs or {}
209
+
210
+ tax_id_registry = platform_configs.get(
211
+ "validation.tax_id_family_registry",
212
+ {
213
+ "VAT-GB": {"pattern": r"^(GB)?[0-9]{9}([0-9]{3})?$", "checksum": "gb_vat_mod97"},
214
+ "VAT-DE": {"pattern": r"^(DE)?[0-9]{9}$", "checksum": "de_vat_mod11"},
215
+ "VAT-FR": {"pattern": r"^(FR)?[A-Z0-9]{2}[0-9]{9}$", "checksum": "fr_vat_mod97"},
216
+ "VAT-IT": {"pattern": r"^(IT)?[0-9]{11}$", "checksum": "it_vat_luhn"},
217
+ "VAT-ES": {"pattern": r"^(ES)?[A-Z0-9][0-9]{7}[A-Z0-9]$", "checksum": "es_nif"},
218
+ "VAT-NL": {"pattern": r"^(NL)?[0-9]{9}B[0-9]{2}$", "checksum": None},
219
+ "GSTIN-IN": {
220
+ "pattern": r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$",
221
+ "checksum": "gstin_crc",
222
+ },
223
+ "PAN-IN": {"pattern": r"^[A-Z]{5}[0-9]{4}[A-Z]$", "checksum": None},
224
+ "EIN-US": {"pattern": r"^[0-9]{2}-?[0-9]{7}$", "checksum": None},
225
+ "ABN-AU": {"pattern": r"^[0-9]{11}$", "checksum": "abn_mod89"},
226
+ "BN-CA": {"pattern": r"^[0-9]{9}([A-Z]{2}[0-9]{4})?$", "checksum": "bn_luhn"},
227
+ "TIN-SG": {"pattern": r"^(?:[STFG][0-9]{7}[A-Z]|[0-9]{9}[A-Z])$", "checksum": "sg_nric_fin"},
228
+ },
229
+ )
230
+
231
+ # Platform can override built-in handlers via validation.tax_id_checksum_handlers.
232
+ # Each handler: callable(value: str, tax_id_type: str | None, country_code: str | None) -> bool
233
+ checksum_handlers = platform_configs.get("validation.tax_id_checksum_handlers", _BUILTIN_CHECKSUM_HANDLERS)
234
+
235
+ def hash_prefix(value, length=8):
236
+ if not value:
237
+ return None
238
+ return sha256(str(value).encode("utf-8")).hexdigest()[:length]
239
+
240
+ def normalise_tax_id(value):
241
+ if value is None:
242
+ return None
243
+
244
+ value = normalize("NFKC", str(value))
245
+ value = re.sub(r"[\s\-\.,/\\:;_]+", "", value)
246
+ value = value.upper().strip()
247
+ return value or None
248
+
249
+ def country_from_tax_id_type(tax_id_type):
250
+ if not tax_id_type or "-" not in tax_id_type:
251
+ return None
252
+ return tax_id_type.split("-")[-1]
253
+
254
+ def registry_entries_for_country(country_code):
255
+ if not country_code:
256
+ return list(tax_id_registry.items())
257
+
258
+ country_code = str(country_code).upper()
259
+ return [
260
+ (tax_id_type, config)
261
+ for tax_id_type, config in tax_id_registry.items()
262
+ if country_from_tax_id_type(tax_id_type) == country_code
263
+ ]
264
+
265
+ def remove_country_prefix(value, tax_id_type):
266
+ country_code = country_from_tax_id_type(tax_id_type)
267
+ if country_code and value.startswith(country_code):
268
+ return value[len(country_code):]
269
+ return value
270
+
271
+ def checksum_valid(value, checksum_name, tax_id_type=None, country_code=None):
272
+ """
273
+ Real checksum gate:
274
+ - if checksum is not configured, treat as valid
275
+ - if a handler is provided by platform configs, use it
276
+ - otherwise fail closed (False) so invalid IDs do not silently pass
277
+ """
278
+ if checksum_name is None:
279
+ return True
280
+
281
+ handler = checksum_handlers.get(checksum_name)
282
+ if callable(handler):
283
+ return bool(handler(value, tax_id_type=tax_id_type, country_code=country_code))
284
+
285
+ return False
286
+
287
+ def canonicalise_tax_id(value, id_type=None, country_code=None):
288
+ stripped = normalise_tax_id(value)
289
+
290
+ if not stripped:
291
+ return {
292
+ "canonical": None,
293
+ "inferred_id_type": None,
294
+ "country_code": None,
295
+ "format_valid": None,
296
+ "checksum_valid": None,
297
+ "validation_passed": None,
298
+ }
299
+
300
+ resolved_id_type = None
301
+ registry_config = None
302
+
303
+ # Master path: direct lookup on (id_type, country_code).
304
+ if id_type:
305
+ resolved_id_type = id_type
306
+ registry_config = tax_id_registry.get(id_type)
307
+ # Cross-check: country embedded in id_type must match supplied country_code.
308
+ if registry_config and country_code:
309
+ embedded = country_from_tax_id_type(id_type)
310
+ if embedded and str(embedded).upper() != str(country_code).upper():
311
+ registry_config = None
312
+
313
+ # Invoice path: infer by trying registry patterns for the supplied country.
314
+ else:
315
+ for candidate_id_type, candidate_config in registry_entries_for_country(country_code):
316
+ if re.match(candidate_config["pattern"], stripped):
317
+ resolved_id_type = candidate_id_type
318
+ registry_config = candidate_config
319
+ break
320
+
321
+ # No registry entry matched: return stripped canonical value, no format/checksum enforcement.
322
+ if not registry_config:
323
+ return {
324
+ "canonical": stripped,
325
+ "inferred_id_type": None,
326
+ "country_code": None,
327
+ "format_valid": None,
328
+ "checksum_valid": None,
329
+ "validation_passed": None,
330
+ }
331
+
332
+ canonical = remove_country_prefix(stripped, resolved_id_type)
333
+ format_valid = bool(re.match(registry_config["pattern"], stripped))
334
+ checksum_ok = checksum_valid(
335
+ canonical,
336
+ registry_config.get("checksum"),
337
+ tax_id_type=resolved_id_type,
338
+ country_code=country_from_tax_id_type(resolved_id_type),
339
+ )
340
+
341
+ return {
342
+ "canonical": canonical,
343
+ "inferred_id_type": resolved_id_type,
344
+ "country_code": country_from_tax_id_type(resolved_id_type),
345
+ "format_valid": format_valid,
346
+ "checksum_valid": checksum_ok,
347
+ "validation_passed": bool(format_valid and checksum_ok),
348
+ }
349
+
350
+ def is_effective(row, invoice_date):
351
+ if getattr(row, "deleted_at", None) is not None:
352
+ return False
353
+
354
+ if invoice_date is None:
355
+ return False
356
+
357
+ effective_from = getattr(row, "effective_from", None)
358
+ effective_to = getattr(row, "effective_to", None)
359
+
360
+ if effective_from and invoice_date < effective_from:
361
+ return False
362
+
363
+ if effective_to and invoice_date >= effective_to:
364
+ return False
365
+
366
+ return True
367
+
368
+ risk_flags = []
369
+ advisory_flags = []
370
+ skipped_steps = []
371
+
372
+ if resolved_vendor_id is None:
373
+ skipped_steps.append(
374
+ {
375
+ "step": "tax_id_validation",
376
+ "skip_reason": "vendor_unresolved",
377
+ }
378
+ )
379
+ return {
380
+ "risk_flags": risk_flags,
381
+ "advisory_flags": advisory_flags,
382
+ "skipped_steps": skipped_steps,
383
+ "audit_payload": None,
384
+ "match_result": "skipped",
385
+ }
386
+
387
+ invoice_tax_id = getattr(extracted_fields, "sender_tax_id", None)
388
+ if not invoice_tax_id:
389
+ skipped_steps.append(
390
+ {
391
+ "step": "tax_id_validation",
392
+ "skip_reason": "no_invoice_tax_id",
393
+ }
394
+ )
395
+ return {
396
+ "risk_flags": risk_flags,
397
+ "advisory_flags": advisory_flags,
398
+ "skipped_steps": skipped_steps,
399
+ "audit_payload": None,
400
+ "match_result": "skipped",
401
+ }
402
+
403
+ invoice_date = getattr(extracted_fields, "invoice_date", None)
404
+ if isinstance(invoice_date, datetime):
405
+ invoice_date = invoice_date.date()
406
+ elif invoice_date is not None and not isinstance(invoice_date, date):
407
+ # Do not invent today's date; treat unknown types as unavailable.
408
+ invoice_date = None
409
+
410
+ vendor_country = getattr(vendor, "billing_country", None)
411
+
412
+ invoice_canonical_result = canonicalise_tax_id(
413
+ invoice_tax_id,
414
+ id_type=None,
415
+ country_code=vendor_country,
416
+ )
417
+
418
+ invoice_canonical = invoice_canonical_result["canonical"]
419
+ invoice_inferred_id_type = invoice_canonical_result["inferred_id_type"]
420
+ invoice_inferred_country = invoice_canonical_result["country_code"]
421
+
422
+ if not invoice_canonical:
423
+ skipped_steps.append({"step": "tax_id_validation", "skip_reason": "no_invoice_tax_id"})
424
+ return {
425
+ "risk_flags": risk_flags,
426
+ "advisory_flags": advisory_flags,
427
+ "skipped_steps": skipped_steps,
428
+ "audit_payload": None,
429
+ "match_result": "skipped",
430
+ }
431
+
432
+ candidate_rows_for_vendor = [
433
+ row
434
+ for row in vendor_tax_ids
435
+ if getattr(row, "tenant_id", None) == document.tenant_id
436
+ and getattr(row, "vendor_id", None) == resolved_vendor_id
437
+ and getattr(row, "deleted_at", None) is None
438
+ ]
439
+
440
+ effective_candidate_rows = [
441
+ row
442
+ for row in candidate_rows_for_vendor
443
+ if is_effective(row, invoice_date)
444
+ ]
445
+
446
+ matching_vendor_tax_row = None
447
+ for row in effective_candidate_rows:
448
+ if getattr(row, "tax_id_canonical", None) == invoice_canonical:
449
+ matching_vendor_tax_row = row
450
+ break
451
+
452
+ master_fallback_result = None
453
+ fallback_master_canonical = None
454
+
455
+ if not candidate_rows_for_vendor and getattr(vendor, "tax_registration_number", None):
456
+ master_fallback_result = canonicalise_tax_id(
457
+ vendor.tax_registration_number,
458
+ id_type=None,
459
+ country_code=vendor_country,
460
+ )
461
+ fallback_master_canonical = master_fallback_result["canonical"]
462
+
463
+ match_result = "unmatched"
464
+ master_hash_prefix = None
465
+ audit_id_type = invoice_inferred_id_type
466
+
467
+ if matching_vendor_tax_row:
468
+ match_result = "matched"
469
+ master_hash_prefix = hash_prefix(matching_vendor_tax_row.tax_id_canonical)
470
+ audit_id_type = getattr(matching_vendor_tax_row, "tax_id_type", None)
471
+
472
+ if getattr(matching_vendor_tax_row, "verified_at", None) is None:
473
+ advisory_flags.append(
474
+ {
475
+ "code": "tax_id_unverified_master",
476
+ "severity": "advisory",
477
+ "vendor_id": resolved_vendor_id,
478
+ }
479
+ )
480
+
481
+ # Country mismatch only after a real successful match path.
482
+ if (
483
+ invoice_inferred_country
484
+ and vendor_country
485
+ and str(invoice_inferred_country).upper() != str(vendor_country).upper()
486
+ ):
487
+ risk_flags.append(
488
+ {
489
+ "code": "tax_id_country_mismatch",
490
+ "severity": "medium",
491
+ "weight": 0.25,
492
+ "invoice_country": invoice_inferred_country,
493
+ "vendor_country": vendor_country,
494
+ }
495
+ )
496
+
497
+ elif not candidate_rows_for_vendor and fallback_master_canonical:
498
+ # Fallback only when there are no structured vendor_tax_ids rows at all.
499
+ master_hash_prefix = hash_prefix(fallback_master_canonical)
500
+
501
+ if invoice_canonical == fallback_master_canonical:
502
+ match_result = "matched_fallback"
503
+ if (
504
+ invoice_inferred_country
505
+ and vendor_country
506
+ and str(invoice_inferred_country).upper() != str(vendor_country).upper()
507
+ ):
508
+ risk_flags.append(
509
+ {
510
+ "code": "tax_id_country_mismatch",
511
+ "severity": "medium",
512
+ "weight": 0.25,
513
+ "invoice_country": invoice_inferred_country,
514
+ "vendor_country": vendor_country,
515
+ }
516
+ )
517
+ else:
518
+ risk_flags.append(
519
+ {
520
+ "code": "tax_id_mismatch",
521
+ "severity": "high",
522
+ "vendor_id": resolved_vendor_id,
523
+ }
524
+ )
525
+
526
+ else:
527
+ risk_flags.append(
528
+ {
529
+ "code": "tax_id_mismatch",
530
+ "severity": "high",
531
+ "vendor_id": resolved_vendor_id,
532
+ }
533
+ )
534
+
535
+ audit_payload = {
536
+ "document_id": document.id,
537
+ "vendor_id": resolved_vendor_id,
538
+ "match_result": match_result,
539
+ "invoice_hash_prefix": hash_prefix(invoice_canonical),
540
+ "master_hash_prefix": master_hash_prefix,
541
+ "id_type": audit_id_type,
542
+ "country_code": invoice_inferred_country or vendor_country,
543
+ "called_at": datetime.now(timezone.utc).isoformat(),
544
+ }
545
+
546
+ return {
547
+ "risk_flags": risk_flags,
548
+ "advisory_flags": advisory_flags,
549
+ "skipped_steps": skipped_steps,
550
+ "audit_payload": audit_payload,
551
+ "match_result": match_result,
552
+ "invoice_tax_id_canonical": invoice_canonical,
553
+ "invoice_tax_id_inferred_type": invoice_inferred_id_type,
554
+ "invoice_tax_id_format_valid": invoice_canonical_result.get("format_valid"),
555
+ "invoice_tax_id_checksum_valid": invoice_canonical_result.get("checksum_valid"),
556
+ }
src/Invoice Validation Functions/Vendor Validation.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from decimal import Decimal
2
+ from hashlib import sha256
3
+ from unicodedata import normalize
4
+ from uuid import uuid4
5
+ from datetime import datetime, timezone
6
+
7
+ from rapidfuzz import fuzz, process
8
+
9
+
10
+ def run_vendor_validation(
11
+ *,
12
+ document,
13
+ extracted_fields,
14
+ vendors,
15
+ platform_configs,
16
+ route_contexts=None,
17
+ actor_id=None,
18
+ ):
19
+ """
20
+ Runs Cat 2 vendor validation using RapidFuzz instead of the platform name-match algorithm.
21
+
22
+ Expected document fields:
23
+ document.id
24
+ document.tenant_id
25
+
26
+ Expected extracted_fields fields:
27
+ sender_name
28
+ sender_name_canonical
29
+ sender_tax_id_canonical
30
+ bank_iban_canonical
31
+
32
+ Expected vendor fields:
33
+ id
34
+ tenant_id
35
+ legal_name
36
+ vendor_name
37
+ legal_name_canonical
38
+ billing_country
39
+ billing_address
40
+ vendor_status
41
+ deleted_at
42
+
43
+ Returns a dict containing:
44
+ resolved_vendor_id
45
+ resolved_sender_name
46
+ resolved_sender_addr
47
+ risk_flags
48
+ advisory_flags
49
+ skipped_steps
50
+ audit_row
51
+ match_result
52
+ """
53
+
54
+ route_contexts = set(route_contexts or [])
55
+
56
+ require_high_tier_contexts = set(
57
+ platform_configs.get(
58
+ "validation.vendor_match_require_high_tier_contexts",
59
+ ["contract_match", "bank_validation", "approval_revalidation"],
60
+ )
61
+ )
62
+
63
+ algorithm_version = platform_configs.get(
64
+ "validation.name_match_algorithm_version",
65
+ "rapidfuzz.local.v1",
66
+ )
67
+
68
+ high_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_high_threshold", "0.90")))
69
+ medium_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_medium_threshold", "0.75")))
70
+ suggestion_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_suggestion_threshold", "0.60")))
71
+
72
+ def canonicalize(value):
73
+ if not value:
74
+ return ""
75
+ return normalize("NFKC", str(value)).lower().strip()
76
+
77
+ def hash_prefix(value, length=8):
78
+ if not value:
79
+ return None
80
+ return sha256(str(value).encode("utf-8")).hexdigest()[:length]
81
+
82
+ def get_vendor_names(vendor):
83
+ names = []
84
+
85
+ for name in (
86
+ getattr(vendor, "legal_name", None),
87
+ getattr(vendor, "vendor_name", None),
88
+ ):
89
+ canonical = canonicalize(name)
90
+ if canonical and canonical not in names:
91
+ names.append(canonical)
92
+
93
+ return names
94
+
95
+ def build_candidate_pool():
96
+ pool = []
97
+
98
+ for vendor in vendors:
99
+ if getattr(vendor, "tenant_id", None) != document.tenant_id:
100
+ continue
101
+
102
+ if getattr(vendor, "vendor_status", None) == "deleted":
103
+ continue
104
+
105
+ if getattr(vendor, "deleted_at", None) is not None:
106
+ continue
107
+
108
+ names = get_vendor_names(vendor)
109
+ if not names:
110
+ continue
111
+
112
+ pool.append(
113
+ {
114
+ "record_id": vendor.id,
115
+ "vendor": vendor,
116
+ "names": names,
117
+ "auxiliary": {
118
+ "country": getattr(vendor, "billing_country", None),
119
+ "tax_id_hash": hash_prefix(
120
+ getattr(extracted_fields, "sender_tax_id_canonical", None)
121
+ ),
122
+ "iban_hash": hash_prefix(
123
+ getattr(extracted_fields, "bank_iban_canonical", None)
124
+ ),
125
+ },
126
+ }
127
+ )
128
+
129
+ return pool
130
+
131
+ def rapidfuzz_name_match(query_name, candidate_pool):
132
+ choices = []
133
+
134
+ for candidate in candidate_pool:
135
+ for name in candidate["names"]:
136
+ choices.append(
137
+ {
138
+ "name": name,
139
+ "record_id": candidate["record_id"],
140
+ "vendor": candidate["vendor"],
141
+ }
142
+ )
143
+
144
+ if not query_name or not choices:
145
+ return {
146
+ "decision": "unmatched",
147
+ "matched_record_id": None,
148
+ "matched_vendor": None,
149
+ "runner_up_record_id": None,
150
+ "runner_up_vendor": None,
151
+ "score": Decimal("0"),
152
+ "score_tier": "low",
153
+ "algorithm_version": algorithm_version,
154
+ "trace_id": str(uuid4()),
155
+ }
156
+
157
+ raw_matches = process.extract(
158
+ query_name,
159
+ [choice["name"] for choice in choices],
160
+ scorer=fuzz.WRatio,
161
+ limit=5,
162
+ )
163
+
164
+ ranked = []
165
+ seen_record_ids = set()
166
+
167
+ for matched_name, score, choice_index in raw_matches:
168
+ choice = choices[choice_index]
169
+ record_id = choice["record_id"]
170
+
171
+ if record_id in seen_record_ids:
172
+ continue
173
+
174
+ seen_record_ids.add(record_id)
175
+
176
+ ranked.append(
177
+ {
178
+ "record_id": record_id,
179
+ "vendor": choice["vendor"],
180
+ "matched_name": matched_name,
181
+ "score": Decimal(str(score / 100)).quantize(Decimal("0.0001")),
182
+ }
183
+ )
184
+
185
+ if not ranked:
186
+ best = None
187
+ runner_up = None
188
+ score = Decimal("0")
189
+ else:
190
+ best = ranked[0]
191
+ runner_up = ranked[1] if len(ranked) > 1 else None
192
+ score = best["score"]
193
+
194
+ if score >= high_threshold:
195
+ decision = "matched"
196
+ score_tier = "high"
197
+ matched_record_id = best["record_id"]
198
+ matched_vendor = best["vendor"]
199
+ elif score >= medium_threshold:
200
+ decision = "matched"
201
+ score_tier = "medium"
202
+ matched_record_id = best["record_id"]
203
+ matched_vendor = best["vendor"]
204
+ elif score >= suggestion_threshold:
205
+ decision = "suggestion"
206
+ score_tier = "low"
207
+ matched_record_id = None
208
+ matched_vendor = None
209
+ else:
210
+ decision = "unmatched"
211
+ score_tier = "low"
212
+ matched_record_id = None
213
+ matched_vendor = None
214
+
215
+ return {
216
+ "decision": decision,
217
+ "matched_record_id": matched_record_id,
218
+ "matched_vendor": matched_vendor,
219
+ "runner_up_record_id": runner_up["record_id"] if runner_up else (
220
+ best["record_id"] if decision == "suggestion" and best else None
221
+ ),
222
+ "runner_up_vendor": runner_up["vendor"] if runner_up else (
223
+ best["vendor"] if decision == "suggestion" and best else None
224
+ ),
225
+ "score": score,
226
+ "score_tier": score_tier,
227
+ "algorithm_version": algorithm_version,
228
+ "trace_id": str(uuid4()),
229
+ }
230
+
231
+ risk_flags = []
232
+ advisory_flags = []
233
+ skipped_steps = []
234
+
235
+ resolved_vendor_id = None
236
+ resolved_sender_name = None
237
+ resolved_sender_addr = None
238
+
239
+ query_name = canonicalize(
240
+ getattr(extracted_fields, "sender_name_canonical", None)
241
+ or getattr(extracted_fields, "sender_name", None)
242
+ )
243
+
244
+ candidate_pool = build_candidate_pool()
245
+
246
+ match_result = rapidfuzz_name_match(
247
+ query_name=query_name,
248
+ candidate_pool=candidate_pool,
249
+ )
250
+
251
+ decision = match_result["decision"]
252
+ score_tier = match_result["score_tier"]
253
+
254
+ control_context_requires_high = bool(route_contexts & require_high_tier_contexts)
255
+
256
+ if decision == "matched" and score_tier == "high":
257
+ resolved_vendor_id = match_result["matched_record_id"]
258
+
259
+ elif decision == "matched" and score_tier == "medium":
260
+ if control_context_requires_high:
261
+ match_result["decision"] = "suggestion"
262
+ advisory_flags.append(
263
+ {
264
+ "code": "vendor_suggestion",
265
+ "severity": "advisory",
266
+ "record_id": match_result["matched_record_id"],
267
+ "reason": "medium_tier_match_downgraded_for_control_context",
268
+ }
269
+ )
270
+ skipped_steps.extend(
271
+ [
272
+ {"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
273
+ {"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
274
+ {"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
275
+ ]
276
+ )
277
+ else:
278
+ resolved_vendor_id = match_result["matched_record_id"]
279
+ risk_flags.append(
280
+ {
281
+ "code": "low_confidence_vendor_resolution",
282
+ "severity": "medium",
283
+ "record_id": resolved_vendor_id,
284
+ "score_tier": score_tier,
285
+ }
286
+ )
287
+
288
+ elif decision == "suggestion":
289
+ advisory_flags.append(
290
+ {
291
+ "code": "vendor_suggestion",
292
+ "severity": "advisory",
293
+ "record_id": match_result["runner_up_record_id"],
294
+ "reason": "possible_vendor_match",
295
+ }
296
+ )
297
+ skipped_steps.extend(
298
+ [
299
+ {"step": "sender_field_override", "skip_reason": "vendor_unresolved"},
300
+ {"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
301
+ {"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
302
+ {"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
303
+ ]
304
+ )
305
+
306
+ else:
307
+ risk_flags.append(
308
+ {
309
+ "code": "unknown_vendor",
310
+ "severity": "high",
311
+ "reason": "no_vendor_match_found",
312
+ }
313
+ )
314
+ skipped_steps.extend(
315
+ [
316
+ {"step": "sender_field_override", "skip_reason": "vendor_unresolved"},
317
+ {"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
318
+ {"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
319
+ {"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
320
+ ]
321
+ )
322
+
323
+ blocked_vendor = None
324
+
325
+ if match_result["decision"] in ("matched", "suggestion"):
326
+ if match_result.get("matched_vendor") and match_result["matched_vendor"].vendor_status == "blocked":
327
+ blocked_vendor = match_result["matched_vendor"]
328
+
329
+ if match_result.get("runner_up_vendor") and match_result["runner_up_vendor"].vendor_status == "blocked":
330
+ blocked_vendor = match_result["runner_up_vendor"]
331
+
332
+ if blocked_vendor:
333
+ risk_flags.append(
334
+ {
335
+ "code": "vendor_blocked",
336
+ "severity": "high",
337
+ "record_id": blocked_vendor.id,
338
+ "reason": "matched_or_suggested_vendor_is_blocked",
339
+ }
340
+ )
341
+
342
+ if resolved_vendor_id:
343
+ resolved_vendor = match_result["matched_vendor"]
344
+
345
+ resolved_sender_name = (
346
+ getattr(resolved_vendor, "legal_name", None)
347
+ or getattr(resolved_vendor, "vendor_name", None)
348
+ )
349
+
350
+ billing_address = getattr(resolved_vendor, "billing_address", None)
351
+ if billing_address is not None:
352
+ resolved_sender_addr = billing_address
353
+
354
+ audit_row = {
355
+ "document_id": document.id,
356
+ "query_name_hash_prefix_8": hash_prefix(query_name, length=8),
357
+ "algorithm_version": algorithm_version,
358
+ "decision": match_result["decision"],
359
+ "matched_record_id": match_result["matched_record_id"],
360
+ "runner_up_record_id": match_result["runner_up_record_id"],
361
+ "score_tier": match_result["score_tier"],
362
+ "score": str(match_result["score"]),
363
+ "trace_id": match_result["trace_id"],
364
+ "called_at": datetime.now(timezone.utc),
365
+ "actor_id": actor_id,
366
+ }
367
+
368
+ return {
369
+ "resolved_vendor_id": resolved_vendor_id,
370
+ "resolved_sender_name": resolved_sender_name,
371
+ "resolved_sender_addr": resolved_sender_addr,
372
+ "risk_flags": risk_flags,
373
+ "advisory_flags": advisory_flags,
374
+ "skipped_steps": skipped_steps,
375
+ "audit_row": audit_row,
376
+ "match_result": {
377
+ "decision": match_result["decision"],
378
+ "matched_record_id": resolved_vendor_id,
379
+ "runner_up_record_id": match_result["runner_up_record_id"],
380
+ "score": match_result["score"],
381
+ "score_tier": match_result["score_tier"],
382
+ "algorithm_version": match_result["algorithm_version"],
383
+ "trace_id": match_result["trace_id"],
384
+ },
385
+ }
src/Invoice Validation Functions/completeness.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from datetime import date, datetime
3
+ from decimal import Decimal, InvalidOperation
4
+
5
+
6
+ def parse_date(value):
7
+ if value in (None, ""):
8
+ return None
9
+ if isinstance(value, datetime):
10
+ return value.date()
11
+ if isinstance(value, date):
12
+ return value
13
+ try:
14
+ return datetime.strptime(str(value), "%Y-%m-%d").date()
15
+ except ValueError:
16
+ return None
17
+
18
+
19
+ def parse_decimal(value):
20
+ if value in (None, ""):
21
+ return None
22
+ if isinstance(value, Decimal):
23
+ return value
24
+ try:
25
+ return Decimal(str(value))
26
+ except (InvalidOperation, ValueError):
27
+ return None
28
+
29
+
30
+ def run_invoice_validation(*, document, extracted_fields, line_items, currency_seed_map, platform_configs=None):
31
+ """
32
+ Business validation logic for:
33
+ 7.7.1 Required fields + due-date derivation path
34
+ 7.7.2 Currency membership
35
+ 7.7.3 Math cross-check with currency-aware tolerance
36
+ """
37
+ platform_configs = platform_configs or {}
38
+
39
+ cap_minor_units = int(
40
+ platform_configs.get("validation.solver_tolerance.cap_minor_units", 0)
41
+ )
42
+ base_per_minor_unit = int(
43
+ platform_configs.get("validation.solver_tolerance.base_per_minor_unit", 0)
44
+ )
45
+ per_line_minor_units = int(
46
+ platform_configs.get("validation.solver_tolerance.per_line_minor_units", 0)
47
+ )
48
+
49
+ risk_flags = []
50
+ skipped_steps = []
51
+
52
+ def add_flag(code, *, weight, detail=None):
53
+ risk_flags.append(
54
+ {
55
+ "code": code,
56
+ "severity": "advisory",
57
+ "weight": weight,
58
+ "detail": detail or {},
59
+ }
60
+ )
61
+
62
+ invoice_no = getattr(extracted_fields, "invoice_no", None)
63
+ invoice_date = parse_date(getattr(extracted_fields, "invoice_date", None))
64
+ total_amount = parse_decimal(getattr(extracted_fields, "total_amount", None))
65
+ subtotal = parse_decimal(getattr(extracted_fields, "subtotal", None))
66
+ sender_name = getattr(extracted_fields, "sender_name", None)
67
+ currency = getattr(extracted_fields, "currency", None)
68
+ due_date = parse_date(getattr(extracted_fields, "due_date", None))
69
+ payment_terms = getattr(extracted_fields, "payment_terms", None)
70
+
71
+ missing_fields = []
72
+
73
+ def mark_missing(field_name):
74
+ if field_name not in missing_fields:
75
+ missing_fields.append(field_name)
76
+
77
+ if not invoice_no:
78
+ mark_missing("invoice_no")
79
+ if invoice_date is None:
80
+ mark_missing("invoice_date")
81
+ if total_amount is None:
82
+ mark_missing("total_amount")
83
+ if not sender_name:
84
+ mark_missing("sender_name")
85
+ if not currency:
86
+ mark_missing("currency")
87
+
88
+ due_date_derivable = invoice_date is not None and payment_terms not in (None, "")
89
+ if due_date is None and not due_date_derivable:
90
+ mark_missing("due_date")
91
+ if invoice_date is None:
92
+ mark_missing("invoice_date")
93
+ if payment_terms in (None, ""):
94
+ mark_missing("payment_terms")
95
+
96
+ if missing_fields:
97
+ add_flag(
98
+ "incomplete_fields",
99
+ weight=0.25,
100
+ detail={"missing_fields": missing_fields},
101
+ )
102
+
103
+ currency_key = str(currency).strip().upper() if currency not in (None, "") else None
104
+ currency_row = currency_seed_map.get(currency_key) if currency_key else None
105
+
106
+ if currency_row is None:
107
+ add_flag(
108
+ "invalid_currency",
109
+ weight=0.20,
110
+ detail={
111
+ "currency": currency,
112
+ "reason": "currency_missing_or_not_in_iso_currencies_seed",
113
+ },
114
+ )
115
+
116
+ line_amounts = []
117
+ for li in line_items:
118
+ amount = parse_decimal(getattr(li, "amount", None))
119
+ if amount is not None:
120
+ line_amounts.append(amount)
121
+
122
+ num_line_items = len(line_amounts)
123
+ math_ran = False
124
+
125
+ if subtotal is not None and num_line_items > 0:
126
+ if currency_row is None:
127
+ skipped_steps.append(
128
+ {"step": "math_cross_check", "skip_reason": "currency_minor_unit_unknown"}
129
+ )
130
+ else:
131
+ minor_unit_value = parse_decimal(currency_row.get("minor_unit_value"))
132
+ if minor_unit_value is None:
133
+ skipped_steps.append(
134
+ {"step": "math_cross_check", "skip_reason": "minor_unit_value_missing_in_seed"}
135
+ )
136
+ else:
137
+ math_ran = True
138
+ line_sum = sum(line_amounts, Decimal("0"))
139
+ tolerance_minor_units = min(
140
+ cap_minor_units,
141
+ base_per_minor_unit + per_line_minor_units * num_line_items,
142
+ )
143
+ tolerance_decimal = Decimal(tolerance_minor_units) * minor_unit_value
144
+ difference = abs(line_sum - subtotal)
145
+
146
+ if difference > tolerance_decimal:
147
+ add_flag(
148
+ "math_error_line_items",
149
+ weight=0.20,
150
+ detail={
151
+ "subtotal": str(subtotal),
152
+ "line_items_sum": str(line_sum),
153
+ "difference": str(difference),
154
+ "tolerance_minor_units": tolerance_minor_units,
155
+ "tolerance_decimal": str(tolerance_decimal),
156
+ "currency": currency_key,
157
+ "minor_unit_value": str(minor_unit_value),
158
+ "num_line_items": num_line_items,
159
+ },
160
+ )
161
+
162
+ return {
163
+ "risk_flags": risk_flags,
164
+ "skipped_steps": skipped_steps,
165
+ "result": "flagged" if risk_flags else "clean",
166
+ "missing_fields": missing_fields,
167
+ "currency_checked": currency_row is not None,
168
+ "math_checked": math_ran,
169
+ }
src/Invoice Validation Functions/date_sanity.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date, datetime, timedelta
2
+ from zoneinfo import ZoneInfo
3
+
4
+
5
+ def run_date_sanity_checks(
6
+ *,
7
+ document,
8
+ extracted_fields,
9
+ platform_configs=None,
10
+ ):
11
+ """
12
+ Runs Date Sanity Checks after date normalisation.
13
+
14
+ Expected document fields:
15
+ document.id
16
+ document.tenant_id
17
+ Optional: document.tenant_timezone
18
+
19
+ Expected extracted_fields fields:
20
+ invoice_date
21
+ due_date
22
+ due_date_origin
23
+ payment_terms
24
+ service_start_date
25
+ service_end_date
26
+
27
+ Returns:
28
+ dict with advisory risk flags and audit-friendly result.
29
+ """
30
+
31
+ platform_configs = platform_configs or {}
32
+
33
+ default_timezone = platform_configs.get(
34
+ "validation.default_timezone",
35
+ getattr(document, "tenant_timezone", "UTC") or "UTC",
36
+ )
37
+
38
+ max_invoice_age_days = int(
39
+ platform_configs.get("validation.max_invoice_age_days", 365)
40
+ )
41
+
42
+ max_invoice_age_days = max(30, min(max_invoice_age_days, 1825))
43
+
44
+ service_end_grace_days = int(
45
+ platform_configs.get("validation.service_end_post_invoice_grace_days", 0)
46
+ )
47
+
48
+ def as_date(value):
49
+ if value is None or value == "":
50
+ return None
51
+
52
+ if isinstance(value, datetime):
53
+ return value.date()
54
+
55
+ if isinstance(value, date):
56
+ return value
57
+
58
+ return datetime.strptime(str(value), "%Y-%m-%d").date()
59
+
60
+ def as_int(value):
61
+ if value is None or value == "":
62
+ return None
63
+ try:
64
+ return int(value)
65
+ except (ValueError, TypeError):
66
+ return None
67
+
68
+ def tenant_current_date():
69
+ try:
70
+ return datetime.now(ZoneInfo(default_timezone)).date()
71
+ except Exception:
72
+ return datetime.now(ZoneInfo("UTC")).date()
73
+
74
+ def add_flag(code, *, weight, field, detail=None):
75
+ risk_flags.append(
76
+ {
77
+ "code": code,
78
+ "severity": "advisory",
79
+ "weight": weight,
80
+ "field": field,
81
+ "detail": detail or {},
82
+ }
83
+ )
84
+
85
+ risk_flags = []
86
+ skipped_steps = []
87
+
88
+ invoice_date = as_date(getattr(extracted_fields, "invoice_date", None))
89
+ due_date = as_date(getattr(extracted_fields, "due_date", None))
90
+ service_start_date = as_date(getattr(extracted_fields, "service_start_date", None))
91
+ service_end_date = as_date(getattr(extracted_fields, "service_end_date", None))
92
+ due_date_origin = getattr(extracted_fields, "due_date_origin", None)
93
+ payment_terms = as_int(getattr(extracted_fields, "payment_terms", None))
94
+
95
+ current_date = tenant_current_date()
96
+
97
+ # 1. Payment terms must be non-negative.
98
+ # Zero is allowed: due-on-receipt.
99
+ if payment_terms is not None and payment_terms < 0:
100
+ add_flag(
101
+ "date_anomaly_payment_terms_negative",
102
+ weight=0.20,
103
+ field="payment_terms",
104
+ detail={
105
+ "payment_terms": payment_terms,
106
+ },
107
+ )
108
+
109
+ # 2. Due date must be on or after invoice date.
110
+ if invoice_date is not None and due_date is not None:
111
+ if due_date < invoice_date:
112
+ add_flag(
113
+ "date_anomaly_due_before_invoice",
114
+ weight=0.20,
115
+ field="due_date",
116
+ detail={
117
+ "invoice_date": str(invoice_date),
118
+ "due_date": str(due_date),
119
+ },
120
+ )
121
+
122
+ if invoice_date is None:
123
+ skipped_steps.extend([
124
+ {"step": "date_anomaly_invoice_future_dated", "skip_reason": "no_invoice_date"},
125
+ {"step": "date_anomaly_invoice_too_old", "skip_reason": "no_invoice_date"},
126
+ ])
127
+
128
+ # 3. Invoice must not be future-dated.
129
+ if invoice_date is not None and invoice_date > current_date:
130
+ add_flag(
131
+ "date_anomaly_invoice_future_dated",
132
+ weight=0.20,
133
+ field="invoice_date",
134
+ detail={
135
+ "invoice_date": str(invoice_date),
136
+ "current_date": str(current_date),
137
+ "timezone": default_timezone,
138
+ },
139
+ )
140
+
141
+ # 4. Invoice must not be too old.
142
+ if invoice_date is not None:
143
+ oldest_allowed_date = current_date - timedelta(days=max_invoice_age_days)
144
+
145
+ if invoice_date < oldest_allowed_date:
146
+ add_flag(
147
+ "date_anomaly_invoice_too_old",
148
+ weight=0.15,
149
+ field="invoice_date",
150
+ detail={
151
+ "invoice_date": str(invoice_date),
152
+ "oldest_allowed_date": str(oldest_allowed_date),
153
+ "max_invoice_age_days": max_invoice_age_days,
154
+ },
155
+ )
156
+
157
+ # 5. Service period must not be reversed.
158
+ if service_start_date is not None and service_end_date is not None:
159
+ if service_end_date < service_start_date:
160
+ add_flag(
161
+ "date_anomaly_service_period_reversed",
162
+ weight=0.15,
163
+ field="service_end_date",
164
+ detail={
165
+ "service_start_date": str(service_start_date),
166
+ "service_end_date": str(service_end_date),
167
+ },
168
+ )
169
+
170
+ # 6. Service end must not be too far after invoice date.
171
+ if service_end_date is not None:
172
+ if invoice_date is None:
173
+ skipped_steps.append(
174
+ {"step": "date_anomaly_service_end_after_invoice", "skip_reason": "no_invoice_date"}
175
+ )
176
+ else:
177
+ allowed_service_end = invoice_date + timedelta(days=service_end_grace_days)
178
+
179
+ if service_end_date > allowed_service_end:
180
+ add_flag(
181
+ "date_anomaly_service_end_after_invoice",
182
+ weight=0.15,
183
+ field="service_end_date",
184
+ detail={
185
+ "invoice_date": str(invoice_date),
186
+ "service_end_date": str(service_end_date),
187
+ "allowed_service_end": str(allowed_service_end),
188
+ "grace_days": service_end_grace_days,
189
+ },
190
+ )
191
+
192
+ # 7. Due date could not be derived.
193
+ if due_date is None and due_date_origin == "unknown":
194
+ add_flag(
195
+ "due_date_not_derivable",
196
+ weight=0.10,
197
+ field="due_date",
198
+ detail={
199
+ "due_date_origin": due_date_origin,
200
+ },
201
+ )
202
+
203
+ return {
204
+ "risk_flags": risk_flags,
205
+ "skipped_steps": skipped_steps,
206
+ "flag_codes": [flag["code"] for flag in risk_flags],
207
+ "checked_fields": {
208
+ "invoice_date": str(invoice_date) if invoice_date else None,
209
+ "due_date": str(due_date) if due_date else None,
210
+ "due_date_origin": due_date_origin,
211
+ "payment_terms": payment_terms,
212
+ "service_start_date": str(service_start_date) if service_start_date else None,
213
+ "service_end_date": str(service_end_date) if service_end_date else None,
214
+ "current_date": str(current_date),
215
+ "timezone": default_timezone,
216
+ "max_invoice_age_days": max_invoice_age_days,
217
+ "service_end_post_invoice_grace_days": service_end_grace_days,
218
+ },
219
+ }
src/Invoice Validation Functions/orchestrator.py 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_checks")) # tax validation uses "skipped_checks"
257
+ ops_alerts.extend(_collect(tax_result, "ops_alerts"))
258
+
259
+ # ------------------------------------------------------------------
260
+ # Step 7 — Completeness / Math Cross-check
261
+ # Independent of vendor resolution.
262
+ # ------------------------------------------------------------------
263
+ completeness_result = _run_completeness_validation(
264
+ document=document,
265
+ extracted_fields=extracted_fields,
266
+ line_items=line_items,
267
+ currency_seed_map=currency_seed_map,
268
+ platform_configs=platform_configs,
269
+ )
270
+ module_results["completeness"] = completeness_result
271
+ risk_flags.extend(_collect(completeness_result, "risk_flags"))
272
+ advisory_flags.extend(_collect(completeness_result, "advisory_flags"))
273
+ skipped_steps.extend(_collect(completeness_result, "skipped_steps"))
274
+
275
+ return {
276
+ "risk_flags": risk_flags,
277
+ "advisory_flags": advisory_flags,
278
+ "skipped_steps": skipped_steps,
279
+ "ops_alerts": ops_alerts,
280
+ "resolved_vendor_id": resolved_vendor_id,
281
+ "module_results": module_results,
282
+ }
src/Invoice Validation Functions/payment terms valdiation.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date, datetime
2
+
3
+
4
+ def run_payment_terms_validation(
5
+ *,
6
+ document,
7
+ extracted_fields,
8
+ vendor,
9
+ purchase_orders,
10
+ contracts,
11
+ resolved_vendor_id,
12
+ platform_configs=None,
13
+ ):
14
+ """
15
+ Payment Terms Validation.
16
+
17
+ Expected document fields:
18
+ document.id
19
+ document.tenant_id
20
+ document.entity_id
21
+
22
+ Expected extracted_fields fields:
23
+ payment_terms
24
+ po_number_canonical
25
+ invoice_date
26
+
27
+ Expected vendor fields:
28
+ vendor.id
29
+ vendor.payment_terms
30
+
31
+ Expected purchase_orders fields:
32
+ tenant_id
33
+ po_number_canonical
34
+ payment_terms
35
+ deleted_at
36
+
37
+ Expected contract fields:
38
+ tenant_id
39
+ vendor_id
40
+ entity_id
41
+ status
42
+ payment_terms
43
+ effective_from
44
+ effective_to
45
+ deleted_at
46
+
47
+ Returns:
48
+ dict with advisory flags, skipped status, and resolution detail.
49
+ """
50
+
51
+ platform_configs = platform_configs or {}
52
+
53
+ tolerance_days = int(
54
+ platform_configs.get("validation.payment_terms_tolerance_days", 0)
55
+ )
56
+
57
+ def as_int(value):
58
+ if value is None or value == "":
59
+ return None
60
+ try:
61
+ return int(value)
62
+ except (ValueError, TypeError):
63
+ return None
64
+
65
+ def as_date(value):
66
+ if value is None or value == "":
67
+ return None
68
+
69
+ if isinstance(value, datetime):
70
+ return value.date()
71
+
72
+ if isinstance(value, date):
73
+ return value
74
+
75
+ return datetime.strptime(str(value), "%Y-%m-%d").date()
76
+
77
+ def add_flag(code, *, weight, detail=None):
78
+ risk_flags.append(
79
+ {
80
+ "code": code,
81
+ "severity": "advisory",
82
+ "weight": weight,
83
+ "detail": detail or {},
84
+ }
85
+ )
86
+
87
+ def po_terms_tier():
88
+ po_number = getattr(extracted_fields, "po_number_canonical", None)
89
+
90
+ if not po_number:
91
+ return None
92
+
93
+ matching_pos = [
94
+ po
95
+ for po in purchase_orders
96
+ if po.tenant_id == document.tenant_id
97
+ and po.po_number_canonical == po_number
98
+ and getattr(po, "deleted_at", None) is None
99
+ and as_int(getattr(po, "payment_terms", None)) is not None
100
+ ]
101
+
102
+ if len(matching_pos) >= 1:
103
+ return {
104
+ "tier": "purchase_order",
105
+ "expected_payment_terms": as_int(matching_pos[0].payment_terms),
106
+ "source_id": getattr(matching_pos[0], "id", None),
107
+ }
108
+
109
+ return None
110
+
111
+ def contract_terms_tier():
112
+ invoice_date = as_date(getattr(extracted_fields, "invoice_date", None))
113
+
114
+ if not invoice_date:
115
+ skipped_steps.append(
116
+ {"step": "contract_payment_terms", "skip_reason": "no_invoice_date"}
117
+ )
118
+ return None
119
+
120
+ matching_contracts = []
121
+
122
+ for contract in contracts:
123
+ if contract.tenant_id != document.tenant_id:
124
+ continue
125
+
126
+ if contract.vendor_id != resolved_vendor_id:
127
+ continue
128
+
129
+ if contract.entity_id != document.entity_id:
130
+ continue
131
+
132
+ if getattr(contract, "status", None) != "active":
133
+ continue
134
+
135
+ if getattr(contract, "deleted_at", None) is not None:
136
+ continue
137
+
138
+ effective_from = as_date(getattr(contract, "effective_from", None))
139
+ effective_to = as_date(getattr(contract, "effective_to", None))
140
+
141
+ if effective_from is None or invoice_date < effective_from:
142
+ continue
143
+
144
+ if effective_to is not None and invoice_date > effective_to:
145
+ continue
146
+
147
+ matching_contracts.append(contract)
148
+
149
+ if len(matching_contracts) != 1:
150
+ return None
151
+
152
+ payment_terms = as_int(getattr(matching_contracts[0], "payment_terms", None))
153
+ if payment_terms is None:
154
+ return None
155
+
156
+ return {
157
+ "tier": "contract",
158
+ "expected_payment_terms": payment_terms,
159
+ "source_id": getattr(matching_contracts[0], "id", None),
160
+ }
161
+
162
+ def vendor_master_terms_tier():
163
+ payment_terms = as_int(getattr(vendor, "payment_terms", None))
164
+
165
+ if payment_terms is None:
166
+ return None
167
+
168
+ return {
169
+ "tier": "vendor_master",
170
+ "expected_payment_terms": payment_terms,
171
+ "source_id": getattr(vendor, "id", None),
172
+ }
173
+
174
+ risk_flags = []
175
+ skipped_steps = []
176
+
177
+ if resolved_vendor_id is None:
178
+ skipped_steps.append(
179
+ {
180
+ "step": "payment_terms_validation",
181
+ "skip_reason": "vendor_unresolved",
182
+ }
183
+ )
184
+ return {
185
+ "risk_flags": risk_flags,
186
+ "skipped_steps": skipped_steps,
187
+ "result": "skipped",
188
+ "expected_payment_terms": None,
189
+ "expected_source_tier": None,
190
+ }
191
+
192
+ invoice_payment_terms = as_int(getattr(extracted_fields, "payment_terms", None))
193
+
194
+ if invoice_payment_terms is None:
195
+ skipped_steps.append(
196
+ {
197
+ "step": "payment_terms_validation",
198
+ "skip_reason": "invoice_payment_terms_missing",
199
+ }
200
+ )
201
+
202
+ return {
203
+ "risk_flags": risk_flags,
204
+ "skipped_steps": skipped_steps,
205
+ "result": "skipped",
206
+ "expected_payment_terms": None,
207
+ "expected_source_tier": None,
208
+ }
209
+
210
+ resolved = (
211
+ po_terms_tier()
212
+ or contract_terms_tier()
213
+ or vendor_master_terms_tier()
214
+ )
215
+
216
+ if resolved is None:
217
+ add_flag(
218
+ "vendor_payment_terms_unconfigured",
219
+ weight=0.10,
220
+ detail={
221
+ "reason": "no_po_contract_or_vendor_payment_terms",
222
+ },
223
+ )
224
+
225
+ return {
226
+ "risk_flags": risk_flags,
227
+ "skipped_steps": skipped_steps,
228
+ "result": "unconfigured",
229
+ "expected_payment_terms": None,
230
+ "expected_source_tier": None,
231
+ }
232
+
233
+ expected_payment_terms = resolved["expected_payment_terms"]
234
+ expected_source_tier = resolved["tier"]
235
+
236
+ difference = abs(invoice_payment_terms - expected_payment_terms)
237
+
238
+ if difference > tolerance_days:
239
+ add_flag(
240
+ "payment_terms_mismatch",
241
+ weight=0.20,
242
+ detail={
243
+ "invoice_payment_terms": invoice_payment_terms,
244
+ "expected_payment_terms": expected_payment_terms,
245
+ "difference_days": difference,
246
+ "tolerance_days": tolerance_days,
247
+ "expected_source_tier": expected_source_tier,
248
+ "source_id": resolved.get("source_id"),
249
+ },
250
+ )
251
+
252
+ result = "mismatch"
253
+ else:
254
+ result = "matched"
255
+
256
+ return {
257
+ "risk_flags": risk_flags,
258
+ "skipped_steps": skipped_steps,
259
+ "result": result,
260
+ "invoice_payment_terms": invoice_payment_terms,
261
+ "expected_payment_terms": expected_payment_terms,
262
+ "expected_source_tier": expected_source_tier,
263
+ "tolerance_days": tolerance_days,
264
+ }
src/Invoice Validation Functions/platform_configs.json 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,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from decimal import Decimal, ROUND_HALF_UP
2
+ from datetime import date, datetime
3
+
4
+
5
+ def run_tax_amount_validation(
6
+ *,
7
+ document,
8
+ extracted_fields,
9
+ line_items,
10
+ vendor,
11
+ entity,
12
+ tax_master_rows,
13
+ platform_configs=None,
14
+ ):
15
+ """
16
+ Tax percentage and tax amount validation.
17
+
18
+ Advisory checks:
19
+ - tax rate exists in tax_master
20
+ - tax amount matches taxable base
21
+ - cross-country routing
22
+ - US sales/use tax advisories
23
+ - UK VAT advisories
24
+ - India GST advisories
25
+ - EU reverse charge
26
+ - vendor withholding advisory
27
+ - vendor tax exemption handling
28
+ """
29
+
30
+ platform_configs = platform_configs or {}
31
+
32
+ tax_rate_tolerance = Decimal(
33
+ str(platform_configs.get("validation.tax_rate_match_tolerance", "0.0001"))
34
+ )
35
+
36
+ currency_minor_units = platform_configs.get(
37
+ "iso_currencies.minor_unit_value",
38
+ {
39
+ "USD": Decimal("0.01"),
40
+ "GBP": Decimal("0.01"),
41
+ "EUR": Decimal("0.01"),
42
+ "INR": Decimal("0.01"),
43
+ "JPY": Decimal("1"),
44
+ "BHD": Decimal("0.001"),
45
+ },
46
+ )
47
+
48
+ eu_member_states = set(
49
+ platform_configs.get(
50
+ "validation.eu_member_states",
51
+ [
52
+ "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
53
+ "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
54
+ "PL", "PT", "RO", "SK", "SI", "ES", "SE",
55
+ ],
56
+ )
57
+ )
58
+
59
+ def as_decimal(value):
60
+ if value is None or value == "":
61
+ return None
62
+ try:
63
+ return Decimal(str(value))
64
+ except Exception:
65
+ return None
66
+
67
+ def country(value):
68
+ if not value:
69
+ return None
70
+ return str(value).upper()
71
+
72
+ def is_zero(value):
73
+ value = as_decimal(value)
74
+ return value is not None and value == Decimal("0")
75
+
76
+ def is_non_zero(value):
77
+ value = as_decimal(value)
78
+ return value is not None and value != Decimal("0")
79
+
80
+ def invoice_date_value():
81
+ value = getattr(extracted_fields, "invoice_date", None)
82
+ if isinstance(value, datetime):
83
+ return value.date()
84
+ if isinstance(value, date):
85
+ return value
86
+ return None # do not invent today's date
87
+
88
+ def currency_tolerance():
89
+ invoice_currency = country(getattr(extracted_fields, "currency", None))
90
+ raw = currency_minor_units.get(invoice_currency)
91
+ return Decimal(str(raw)) if raw is not None else None
92
+
93
+ def add_flag(code, *, weight=None, detail=None):
94
+ flag = {"code": code, "severity": "advisory"}
95
+ if weight is not None:
96
+ flag["weight"] = weight
97
+ if detail is not None:
98
+ flag["detail"] = detail
99
+ risk_flags.append(flag)
100
+
101
+ def add_advisory(code, *, detail=None):
102
+ advisory = {"code": code, "severity": "advisory"}
103
+ if detail is not None:
104
+ advisory["detail"] = detail
105
+ # avoid duplicates
106
+ if not any(a["code"] == code for a in advisory_flags):
107
+ advisory_flags.append(advisory)
108
+
109
+ def add_skip(check_name, reason):
110
+ skipped_checks.append({"check": check_name, "skip_reason": reason})
111
+
112
+ def effective_tax_master_rows():
113
+ invoice_date = invoice_date_value()
114
+ rows = []
115
+ for row in tax_master_rows:
116
+ if getattr(row, "tenant_id", None) != document.tenant_id:
117
+ continue
118
+ if not getattr(row, "is_active", True):
119
+ continue
120
+ if getattr(row, "deleted_at", None) is not None:
121
+ continue
122
+
123
+ # If invoice date is unavailable, do not invent one.
124
+ # Keep only active rows, but skip date-sensitive matching later if needed.
125
+ if invoice_date is not None:
126
+ effective_from = getattr(row, "effective_from", None)
127
+ effective_to = getattr(row, "effective_to", None)
128
+
129
+ if effective_from is not None and effective_from > invoice_date:
130
+ continue
131
+ if effective_to is not None and invoice_date >= effective_to:
132
+ continue
133
+
134
+ rows.append(row)
135
+ return rows
136
+
137
+ def rate_matches(master_rate, invoice_rate):
138
+ master_rate = as_decimal(master_rate)
139
+ invoice_rate = as_decimal(invoice_rate)
140
+ if master_rate is None or invoice_rate is None:
141
+ return False
142
+ return abs(master_rate - invoice_rate) <= tax_rate_tolerance
143
+
144
+ def row_region_matches(row_region, hint_region):
145
+ if row_region is None:
146
+ return True
147
+ return hint_region is not None and str(row_region).upper() == str(hint_region).upper()
148
+
149
+ def find_tax_master_rate(
150
+ *,
151
+ rate,
152
+ country_code,
153
+ region_code=None,
154
+ allowed_tax_types=None,
155
+ reduced_rate_allowed=False,
156
+ ):
157
+ country_code = country(country_code)
158
+ matches = []
159
+
160
+ for row in active_tax_master:
161
+ if country(getattr(row, "country_code", None)) != country_code:
162
+ continue
163
+
164
+ if not row_region_matches(getattr(row, "region_code", None), region_code):
165
+ continue
166
+
167
+ if allowed_tax_types is not None and getattr(row, "tax_type", None) not in allowed_tax_types:
168
+ continue
169
+
170
+ if rate_matches(getattr(row, "tax_rate", None), rate):
171
+ matches.append(row)
172
+ continue
173
+
174
+ elif reduced_rate_allowed:
175
+ tax_name = str(getattr(row, "tax_name", "") or "").lower()
176
+ if "reduced" in tax_name:
177
+ matches.append(row)
178
+
179
+ return matches
180
+
181
+ def find_india_gst_aggregate_rate(rate, region_code, jurisdiction):
182
+ """
183
+ Match invoice aggregate GST rate against the sum of applicable component rates:
184
+ inter_state : IGST single-row match
185
+ intra_state : CGST + SGST pair whose rates sum to the invoice rate
186
+ None/unknown: try IGST, then CGST+SGST, then CGST+UTGST
187
+ """
188
+
189
+ def in_rows(tax_type, rgn=None):
190
+ return [
191
+ r for r in active_tax_master
192
+ if country(getattr(r, "country_code", None)) == "IN"
193
+ and getattr(r, "tax_type", None) == tax_type
194
+ and row_region_matches(getattr(r, "region_code", None), rgn)
195
+ ]
196
+
197
+ def sum_pair(type_a, type_b, rgn=None):
198
+ for a in in_rows(type_a, rgn):
199
+ a_rate = as_decimal(getattr(a, "tax_rate", None))
200
+ if a_rate is None:
201
+ continue
202
+ for b in in_rows(type_b, rgn):
203
+ b_rate = as_decimal(getattr(b, "tax_rate", None))
204
+ if b_rate is None:
205
+ continue
206
+ if abs((a_rate + b_rate) - rate) <= tax_rate_tolerance:
207
+ return [a, b]
208
+ return []
209
+
210
+ if jurisdiction == "inter_state":
211
+ for r in in_rows("IGST", region_code):
212
+ if rate_matches(getattr(r, "tax_rate", None), rate):
213
+ return [r]
214
+ return []
215
+
216
+ if jurisdiction == "intra_state":
217
+ return sum_pair("CGST", "SGST", region_code)
218
+
219
+ # Unknown jurisdiction: try all applicable component combinations.
220
+ for r in in_rows("IGST"):
221
+ if rate_matches(getattr(r, "tax_rate", None), rate):
222
+ return [r]
223
+ result = sum_pair("CGST", "SGST")
224
+ if result:
225
+ return result
226
+ return sum_pair("CGST", "UTGST")
227
+
228
+ def all_line_rates_zero():
229
+ for line in line_items:
230
+ rate = as_decimal(getattr(line, "tax_rate_per_item", None))
231
+ if rate is None:
232
+ continue
233
+ if rate != Decimal("0"):
234
+ return False
235
+ return True
236
+
237
+ def all_line_tax_amounts_zero():
238
+ found_any = False
239
+ for line in line_items:
240
+ amount = as_decimal(getattr(line, "tax_amount_per_item", None))
241
+ if amount is None:
242
+ continue
243
+ found_any = True
244
+ if amount != Decimal("0"):
245
+ return False
246
+
247
+ summary_tax_amount = as_decimal(getattr(extracted_fields, "tax_amount", None))
248
+ if summary_tax_amount is not None:
249
+ found_any = True
250
+ if summary_tax_amount != Decimal("0"):
251
+ return False
252
+
253
+ return found_any
254
+
255
+ def sender_tax_family():
256
+ return getattr(extracted_fields, "sender_tax_id_inferred_family", None)
257
+
258
+ def sender_tax_valid_for_country(expected_family):
259
+ return (
260
+ getattr(extracted_fields, "sender_tax_id", None) is not None
261
+ and sender_tax_family() == expected_family
262
+ and bool(getattr(extracted_fields, "sender_tax_id_format_valid", True))
263
+ and bool(getattr(extracted_fields, "sender_tax_id_checksum_valid", True))
264
+ )
265
+
266
+ def supplier_and_recipient_are_eu():
267
+ return supplier_country in eu_member_states and recipient_country in eu_member_states
268
+
269
+ def eu_reverse_charge_conditions_met():
270
+ if not supplier_and_recipient_are_eu():
271
+ return False
272
+ if supplier_country == recipient_country:
273
+ return False
274
+
275
+ if vendor_exemption_status == "reverse_charge":
276
+ return True
277
+
278
+ supplier_family = f"VAT-{supplier_country}"
279
+ supplier_vat_valid = sender_tax_valid_for_country(supplier_family)
280
+ recipient_vat_present = bool(getattr(entity, "vat_id", None))
281
+
282
+ return (
283
+ supplier_vat_valid
284
+ and recipient_vat_present
285
+ and all_line_rates_zero()
286
+ and is_zero(getattr(extracted_fields, "tax_amount", None))
287
+ )
288
+
289
+ def india_jurisdiction():
290
+ supplier_state = getattr(vendor, "billing_state", None)
291
+ recipient_state = getattr(entity, "region_code", None)
292
+ if not supplier_state or not recipient_state:
293
+ return None
294
+ if str(supplier_state).upper() == str(recipient_state).upper():
295
+ return "intra_state"
296
+ return "inter_state"
297
+
298
+ risk_flags = []
299
+ advisory_flags = []
300
+ skipped_checks = []
301
+ ops_alerts = []
302
+ matched_tax_master_rows = {}
303
+
304
+ active_tax_master = effective_tax_master_rows()
305
+
306
+ supplier_country = country(getattr(vendor, "billing_country", None))
307
+ recipient_country = country(getattr(entity, "country_code", None))
308
+ invoice_currency = country(getattr(extracted_fields, "currency", None))
309
+ vendor_exemption_status = getattr(vendor, "exemption_status", None)
310
+ invoice_date = invoice_date_value()
311
+
312
+ if not active_tax_master:
313
+ ops_alerts.append(
314
+ {
315
+ "code": "alert.tax_master_unconfigured",
316
+ "tenant_id": document.tenant_id,
317
+ }
318
+ )
319
+ return {
320
+ "risk_flags": risk_flags,
321
+ "advisory_flags": advisory_flags,
322
+ "skipped_checks": [
323
+ {"check": "all_tax_checks", "skip_reason": "tax_master_unconfigured"}
324
+ ],
325
+ "ops_alerts": ops_alerts,
326
+ "matched_tax_master_rows": matched_tax_master_rows,
327
+ }
328
+
329
+ # Check 1 and Check 2 both skip if supplier country is missing.
330
+ if not supplier_country:
331
+ add_skip("rate_exists_in_master", "supplier_country_missing")
332
+ add_skip("tax_amount_matches_taxable_base", "supplier_country_missing")
333
+ else:
334
+ unique_rates = set()
335
+
336
+ # Gather unique non-null, non-zero line rates
337
+ for line in line_items:
338
+ rate = as_decimal(getattr(line, "tax_rate_per_item", None))
339
+ if rate is not None and rate != Decimal("0"):
340
+ unique_rates.add(
341
+ (
342
+ rate,
343
+ getattr(line, "region_code_hint", None),
344
+ getattr(line, "line_number", None),
345
+ )
346
+ )
347
+
348
+ # Summary tax rate is checked separately
349
+ summary_rate = as_decimal(getattr(extracted_fields, "tax_rate", None))
350
+ if summary_rate is not None and summary_rate != Decimal("0"):
351
+ unique_rates.add((summary_rate, None, "summary"))
352
+
353
+ for rate, region_code, source in unique_rates:
354
+ # India aggregate rate matching is handled entirely in Check 6.
355
+ if supplier_country == "IN":
356
+ continue
357
+
358
+ # US state tax needs a region hint
359
+ if supplier_country == "US" and not region_code:
360
+ add_advisory("us_region_unresolved", detail={"source": source})
361
+ continue
362
+
363
+ allowed_tax_types = None
364
+ if supplier_country == "GB":
365
+ allowed_tax_types = {"VAT"}
366
+ if supplier_country == "IN":
367
+ allowed_tax_types = {"CGST", "SGST", "IGST", "UTGST"}
368
+ if supplier_country == "US":
369
+ allowed_tax_types = None
370
+
371
+ reduced_rate_allowed = vendor_exemption_status == "reduced_rate"
372
+
373
+ matches = find_tax_master_rate(
374
+ rate=rate,
375
+ country_code=supplier_country,
376
+ region_code=region_code,
377
+ allowed_tax_types=allowed_tax_types,
378
+ reduced_rate_allowed=reduced_rate_allowed,
379
+ )
380
+
381
+ if matches:
382
+ matched_tax_master_rows[source] = [getattr(row, "id", None) for row in matches]
383
+ else:
384
+ add_flag(
385
+ "tax_rate_not_in_master",
386
+ weight=Decimal("0.35"),
387
+ detail={
388
+ "rate": str(rate),
389
+ "country": supplier_country,
390
+ "region_code": region_code,
391
+ "source": source,
392
+ },
393
+ )
394
+
395
+
396
+ # Check 2: Amount matches taxable base.
397
+ if supplier_country:
398
+ tolerance = currency_tolerance()
399
+ if tolerance is None:
400
+ add_skip("tax_amount_matches_taxable_base", "currency_minor_unit_unknown")
401
+ else:
402
+ for line in line_items:
403
+ rate = as_decimal(getattr(line, "tax_rate_per_item", None))
404
+ amount = as_decimal(getattr(line, "amount", None))
405
+ actual_tax = as_decimal(getattr(line, "tax_amount_per_item", None))
406
+
407
+ if rate is None or amount is None or actual_tax is None:
408
+ continue
409
+
410
+ discount = as_decimal(getattr(line, "discount_amount_per_item", None)) or Decimal("0")
411
+ taxable_base = amount - discount
412
+ expected_tax = taxable_base * (rate / Decimal("100"))
413
+
414
+ if abs(expected_tax - actual_tax) > tolerance:
415
+ add_flag(
416
+ "tax_amount_mismatch",
417
+ weight=Decimal("0.35"),
418
+ detail={
419
+ "line_number": getattr(line, "line_number", None),
420
+ "expected_tax": str(expected_tax.quantize(tolerance, rounding=ROUND_HALF_UP)),
421
+ "actual_tax": str(actual_tax),
422
+ "currency": invoice_currency,
423
+ },
424
+ )
425
+
426
+ # §8.9: vendor_tax_exempt advisory fires when all extracted line rates are zero.
427
+ if vendor_exemption_status == "exempt" and all_line_rates_zero():
428
+ add_advisory("vendor_tax_exempt")
429
+
430
+ # Check 3: Cross-country routing.
431
+ summary_tax_amount = as_decimal(getattr(extracted_fields, "tax_amount", None))
432
+
433
+ if supplier_country is None or summary_tax_amount is None or summary_tax_amount == Decimal("0"):
434
+ add_skip("cross_country_routing", "supplier_country_or_tax_amount_missing_or_zero")
435
+ elif recipient_country is None:
436
+ add_skip("cross_country_routing", "recipient_country_missing")
437
+ elif supplier_country != recipient_country:
438
+ add_flag(
439
+ "cross_country_tax",
440
+ weight=Decimal("0.30"),
441
+ detail={
442
+ "supplier_country": supplier_country,
443
+ "recipient_country": recipient_country,
444
+ },
445
+ )
446
+
447
+ # Check 4: United States.
448
+ if supplier_country == "US" or recipient_country == "US":
449
+ if supplier_country == "US" and summary_tax_amount is not None and summary_tax_amount != Decimal("0"):
450
+ add_flag(
451
+ "us_sales_tax",
452
+ weight=Decimal("0.20"),
453
+ detail={"tax_amount": str(summary_tax_amount)},
454
+ )
455
+
456
+ if (
457
+ recipient_country == "US"
458
+ and supplier_country != recipient_country
459
+ and all_line_rates_zero()
460
+ ):
461
+ add_flag(
462
+ "us_use_tax_possibly_owed",
463
+ weight=Decimal("0.15"),
464
+ )
465
+
466
+ # Check 5: United Kingdom.
467
+ if supplier_country == "GB" or recipient_country == "GB":
468
+ for line in line_items:
469
+ rate = as_decimal(getattr(line, "tax_rate_per_item", None))
470
+ tax_amount = as_decimal(getattr(line, "tax_amount_per_item", None))
471
+
472
+ if rate == Decimal("0"):
473
+ gb_matches = find_tax_master_rate(
474
+ rate=rate,
475
+ country_code="GB",
476
+ region_code=getattr(line, "region_code_hint", None),
477
+ allowed_tax_types={"zero_rated", "exempt", "VAT"},
478
+ reduced_rate_allowed=False,
479
+ )
480
+
481
+ if any(getattr(row, "tax_type", None) == "zero_rated" for row in gb_matches):
482
+ add_advisory("uk_zero_rated", detail={"line_number": getattr(line, "line_number", None)})
483
+ elif tax_amount == Decimal("0") and any(
484
+ getattr(row, "tax_type", None) == "exempt" for row in gb_matches
485
+ ):
486
+ add_advisory("uk_exempt", detail={"line_number": getattr(line, "line_number", None)})
487
+ elif not (
488
+ vendor_exemption_status == "reverse_charge"
489
+ and supplier_and_recipient_are_eu()
490
+ and supplier_country != recipient_country
491
+ ) and not (
492
+ vendor_exemption_status == "exempt"
493
+ and all_line_rates_zero()
494
+ ):
495
+ add_flag(
496
+ "tax_rate_not_in_master",
497
+ weight=Decimal("0.35"),
498
+ detail={
499
+ "rate": "0",
500
+ "country": "GB",
501
+ "source": getattr(line, "line_number", None),
502
+ },
503
+ )
504
+
505
+ if (
506
+ supplier_country == "GB"
507
+ and recipient_country == "GB"
508
+ and summary_tax_amount is not None
509
+ and summary_tax_amount != Decimal("0")
510
+ and not sender_tax_valid_for_country("VAT-GB")
511
+ ):
512
+ add_flag(
513
+ "missing_tax_id_for_local_tax",
514
+ weight=Decimal("0.25"),
515
+ detail={"expected_family": "VAT-GB"},
516
+ )
517
+
518
+ # Check 6: India GST.
519
+ if supplier_country == "IN" or recipient_country == "IN":
520
+ jurisdiction = india_jurisdiction()
521
+ if jurisdiction is None:
522
+ add_advisory("in_gst_jurisdiction_unresolved")
523
+ else:
524
+ add_advisory("in_gst_jurisdiction_inferred", detail={"jurisdiction": jurisdiction})
525
+
526
+ # Sender tax ID must look like GSTIN-IN or PAN-IN
527
+ if sender_tax_family() not in {"GSTIN-IN", "PAN-IN"}:
528
+ add_flag(
529
+ "missing_tax_id_for_local_tax",
530
+ weight=Decimal("0.25"),
531
+ detail={"expected_family": "GSTIN-IN or PAN-IN"},
532
+ )
533
+
534
+ for line in line_items:
535
+ rate = as_decimal(getattr(line, "tax_rate_per_item", None))
536
+ if rate is None or rate == Decimal("0"):
537
+ continue
538
+
539
+ region_code = None
540
+ if jurisdiction == "intra_state":
541
+ region_code = getattr(vendor, "billing_state", None) or getattr(entity, "region_code", None)
542
+
543
+ matches = find_india_gst_aggregate_rate(rate, region_code, jurisdiction)
544
+
545
+ if matches:
546
+ matched_tax_master_rows[getattr(line, "line_number", None)] = [
547
+ getattr(r, "id", None) for r in matches
548
+ ]
549
+ else:
550
+ add_flag(
551
+ "tax_rate_not_in_master",
552
+ weight=Decimal("0.35"),
553
+ detail={
554
+ "rate": str(rate),
555
+ "country": "IN",
556
+ "region_code": region_code,
557
+ "source": getattr(line, "line_number", None),
558
+ },
559
+ )
560
+
561
+ # Check 7: EU reverse charge.
562
+ # If conditions are met, add advisory and retract cross_country_tax raised by Check 3.
563
+ if supplier_and_recipient_are_eu() and supplier_country != recipient_country:
564
+ if eu_reverse_charge_conditions_met():
565
+ add_advisory("eu_reverse_charge")
566
+ risk_flags[:] = [f for f in risk_flags if f["code"] != "cross_country_tax"]
567
+
568
+ # Check 8: Withholding tax advisory.
569
+ if vendor_exemption_status == "withholding_applicable":
570
+ add_advisory("vendor_withholding_applicable")
571
+
572
+ for line in line_items:
573
+ tax_master_id = getattr(line, "tax_master_id", None)
574
+ if not tax_master_id:
575
+ continue
576
+ for row in active_tax_master:
577
+ if getattr(row, "id", None) == tax_master_id and getattr(row, "tax_type", None) == "withholding":
578
+ add_advisory("vendor_withholding_applicable")
579
+ break
580
+
581
+ return {
582
+ "risk_flags": risk_flags,
583
+ "advisory_flags": advisory_flags,
584
+ "skipped_checks": skipped_checks,
585
+ "ops_alerts": ops_alerts,
586
+ "matched_tax_master_rows": matched_tax_master_rows,
587
+ }
src/Matching Functions/contract_match.py ADDED
@@ -0,0 +1,757 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import date, datetime
5
+ from decimal import Decimal, ROUND_HALF_EVEN, getcontext
6
+ from typing import Any, Callable, Optional
7
+
8
+ import re
9
+ import unicodedata
10
+
11
+ from rapidfuzz import fuzz
12
+
13
+ from two_way_match import MatchExceptionRecord, InvoiceLine, to_decimal
14
+
15
+ # ============================================================
16
+ # Decimal hygiene
17
+ # ============================================================
18
+ getcontext().prec = 28
19
+ getcontext().rounding = ROUND_HALF_EVEN
20
+
21
+ D0 = Decimal("0")
22
+ D100 = Decimal("100")
23
+
24
+
25
+ # ============================================================
26
+ # Data models
27
+ # ============================================================
28
+ @dataclass(frozen=True)
29
+ class RateCardItem:
30
+ id: int
31
+ description: Optional[str] = None
32
+ description_canonical: Optional[str] = None
33
+ unit_price: Optional[Decimal] = None
34
+ item_type: Optional[str] = None # goods | services | None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ContractRecord:
39
+ id: int
40
+ tenant_id: int
41
+ vendor_id: int
42
+ entity_id: int
43
+ currency: str
44
+ status: str = "active"
45
+ deleted_at: Any = None
46
+ effective_from: Optional[date] = None
47
+ effective_to: Optional[date] = None
48
+ created_at: Optional[datetime] = None
49
+ total_value: Optional[Decimal] = None
50
+ cumulative_billed_amount: Optional[Decimal] = None
51
+ rate_card_items: Optional[list[RateCardItem]] = None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ContractInvoiceHeader:
56
+ id: int
57
+ tenant_id: int
58
+ entity_id: int
59
+ resolved_vendor_id: Optional[int]
60
+ currency: Optional[str]
61
+ invoice_date: Optional[date]
62
+ service_start_date: Optional[date] = None
63
+ service_end_date: Optional[date] = None
64
+ total_amount: Optional[Decimal] = None
65
+ advisory_flags: tuple[str, ...] = ()
66
+
67
+
68
+ @dataclass
69
+ class ContractLineResult:
70
+ invoice_line_id: int
71
+ rate_card_item_id: Optional[int]
72
+ line_status: str = "matched" # matched | no_match | exception
73
+ line_zone: str = "green" # green | amber | red
74
+ price_var_pct: Optional[Decimal] = None
75
+ allocated_inv_amount: Optional[Decimal] = None
76
+ allocated_rate_amount: Optional[Decimal] = None
77
+ exception_type: Optional[str] = None
78
+ exception_detail: dict[str, Any] = field(default_factory=dict)
79
+
80
+
81
+ @dataclass
82
+ class ContractMatchResult:
83
+ match_type: str = "contract"
84
+ overall_status: str = "exception" # matched | partial_match | exception
85
+ match_zone: str = "red" # green | amber | red | critical
86
+ documents_status: str = "pending_approval"
87
+
88
+ lines_total: int = 0
89
+ lines_matched: int = 0
90
+ lines_in_exception: int = 0
91
+
92
+ advisory_flags: list[str] = field(default_factory=list)
93
+ risk_flags: list[str] = field(default_factory=list)
94
+ skipped_steps: list[dict] = field(default_factory=list)
95
+ header_exceptions: list[MatchExceptionRecord] = field(default_factory=list)
96
+ match_exceptions: list[MatchExceptionRecord] = field(default_factory=list)
97
+ match_line_results: list[ContractLineResult] = field(default_factory=list)
98
+
99
+ selected_contract_id: Optional[int] = None
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class ContractConfig:
104
+ # RapidFuzz thresholds for rate-card / tiebreaker matching
105
+ high_threshold: float = 90.0
106
+ medium_threshold: float = 80.0
107
+ score_cutoff: float = 80.0
108
+
109
+ # Price variance bands (same idea as 2-way; tune to your config table)
110
+ price_goods_green: Decimal = Decimal("5")
111
+ price_goods_amber: Decimal = Decimal("10")
112
+ price_services_green: Decimal = Decimal("10")
113
+ price_services_amber: Decimal = Decimal("15")
114
+
115
+ precision: int = 28
116
+
117
+
118
+ # ============================================================
119
+ # Helpers
120
+ # ============================================================
121
+ def canonicalise_identifier(value: Optional[str]) -> Optional[str]:
122
+ if value is None:
123
+ return None
124
+ value = unicodedata.normalize("NFKC", str(value)).upper()
125
+ value = re.sub(r"[^A-Z0-9]+", "", value)
126
+ return value or None
127
+
128
+
129
+ def norm_currency(value: Optional[str]) -> Optional[str]:
130
+ return value.upper().strip() if value else None
131
+
132
+
133
+ def price_variance_pct(inv_price: Optional[Decimal], ref_price: Optional[Decimal]) -> Optional[Decimal]:
134
+ if inv_price is None or ref_price is None or ref_price == 0:
135
+ return None
136
+ return (abs(inv_price - ref_price) / ref_price * D100).quantize(Decimal("0.0001"))
137
+
138
+
139
+ def _bands(item_type: Optional[str], cfg: ContractConfig) -> tuple[Decimal, Decimal]:
140
+ item_type = (item_type or "services").lower()
141
+ if item_type == "goods":
142
+ return cfg.price_goods_green, cfg.price_goods_amber
143
+ return cfg.price_services_green, cfg.price_services_amber
144
+
145
+
146
+ def _line_zone_from_price(price_pct: Optional[Decimal], item_type: Optional[str], cfg: ContractConfig) -> str:
147
+ if price_pct is None:
148
+ return "green"
149
+ green, amber = _bands(item_type, cfg)
150
+ if price_pct <= green:
151
+ return "green"
152
+ if price_pct <= amber:
153
+ return "amber"
154
+ return "red"
155
+
156
+
157
+ def _is_service_window_contained(
158
+ contract: ContractRecord,
159
+ invoice_start: Optional[date],
160
+ invoice_end: Optional[date],
161
+ ) -> bool:
162
+ if invoice_start is None or invoice_end is None:
163
+ return False
164
+ if contract.effective_from is None:
165
+ return False
166
+ effective_to = contract.effective_to or date(9999, 12, 31)
167
+ return contract.effective_from <= invoice_start and invoice_end <= effective_to
168
+
169
+
170
+ def _joined_invoice_descriptions(inv_lines: list[InvoiceLine]) -> str:
171
+ parts = []
172
+ for line in inv_lines:
173
+ if line.description_canonical:
174
+ parts.append(line.description_canonical)
175
+ elif line.description:
176
+ parts.append(canonicalise_identifier(line.description) or "")
177
+ return " ".join(p for p in parts if p).strip()
178
+
179
+
180
+ def rapidfuzz_best_match(
181
+ query: str,
182
+ candidates: list[dict[str, Any]],
183
+ *,
184
+ high_threshold: float,
185
+ medium_threshold: float,
186
+ score_cutoff: float,
187
+ ) -> dict[str, Any]:
188
+ """
189
+ Simple local matcher.
190
+ Swap this out with your platform name-match algorithm later if required.
191
+ """
192
+ if not query or not candidates:
193
+ return {"decision": "unmatched", "score_tier": None, "score": 0.0, "record_id": None}
194
+
195
+ best_record_id = None
196
+ best_score = 0.0
197
+
198
+ for cand in candidates:
199
+ record_id = cand.get("record_id")
200
+ names = cand.get("names") or []
201
+ for name in names:
202
+ if not name:
203
+ continue
204
+ score = float(fuzz.WRatio(query, name, score_cutoff=score_cutoff))
205
+ if score > best_score:
206
+ best_score = score
207
+ best_record_id = record_id
208
+
209
+ if best_record_id is None or best_score < score_cutoff:
210
+ return {"decision": "unmatched", "score_tier": None, "score": 0.0, "record_id": None}
211
+
212
+ if best_score >= high_threshold:
213
+ tier = "high"
214
+ elif best_score >= medium_threshold:
215
+ tier = "medium"
216
+ else:
217
+ tier = "low"
218
+
219
+ return {
220
+ "decision": "matched" if tier in {"high", "medium"} else "suggestion",
221
+ "score_tier": tier,
222
+ "score": round(best_score, 2),
223
+ "record_id": best_record_id,
224
+ }
225
+
226
+
227
+ def _contract_stub_result(
228
+ invoice: ContractInvoiceHeader,
229
+ inv_lines: list[InvoiceLine],
230
+ exception_type: str,
231
+ detail: dict[str, Any],
232
+ ) -> ContractMatchResult:
233
+ res = ContractMatchResult(
234
+ overall_status="exception",
235
+ match_zone="red",
236
+ documents_status="pending_approval",
237
+ lines_total=len(inv_lines),
238
+ lines_matched=0,
239
+ lines_in_exception=len(inv_lines),
240
+ )
241
+ res.header_exceptions.append(
242
+ MatchExceptionRecord(
243
+ exception_type=exception_type,
244
+ exception_detail=detail,
245
+ )
246
+ )
247
+ return res
248
+
249
+
250
+ # ============================================================
251
+ # Contract candidate selection
252
+ # ============================================================
253
+ def select_contract_candidate(
254
+ invoice: ContractInvoiceHeader,
255
+ inv_lines: list[InvoiceLine],
256
+ contracts: list[ContractRecord],
257
+ cfg: ContractConfig,
258
+ ) -> tuple[Optional[ContractRecord], Optional[MatchExceptionRecord]]:
259
+ """
260
+ Returns:
261
+ - selected contract
262
+ - or a MatchExceptionRecord for stub paths
263
+ """
264
+ inv_currency = norm_currency(invoice.currency)
265
+
266
+ initial_candidates: list[dict[str, Any]] = []
267
+ for c in contracts:
268
+ if c.deleted_at is not None:
269
+ continue
270
+ if c.status != "active":
271
+ continue
272
+ if c.tenant_id != invoice.tenant_id:
273
+ continue
274
+ if c.vendor_id != invoice.resolved_vendor_id:
275
+ continue
276
+ if c.entity_id != invoice.entity_id:
277
+ continue
278
+ if norm_currency(c.currency) != inv_currency:
279
+ continue
280
+ if invoice.invoice_date is None:
281
+ continue
282
+ if c.effective_from is None:
283
+ continue
284
+ effective_to = c.effective_to or date(9999, 12, 31)
285
+ if not (c.effective_from <= invoice.invoice_date < effective_to):
286
+ continue
287
+
288
+ initial_candidates.append(
289
+ {
290
+ "contract": c,
291
+ "elimination_reason": None,
292
+ }
293
+ )
294
+
295
+ if not initial_candidates:
296
+ return None, MatchExceptionRecord(
297
+ exception_type="contract_not_found",
298
+ exception_detail={
299
+ "reason": "initial_query_returned_zero_rows",
300
+ "tenant_id": invoice.tenant_id,
301
+ "vendor_id": invoice.resolved_vendor_id,
302
+ "entity_id": invoice.entity_id,
303
+ "currency": inv_currency,
304
+ "invoice_date": str(invoice.invoice_date) if invoice.invoice_date else None,
305
+ },
306
+ )
307
+
308
+ remaining = [x["contract"] for x in initial_candidates]
309
+
310
+ # Tiebreaker 1: service-period containment (only when both service dates are present)
311
+ if invoice.service_start_date is not None and invoice.service_end_date is not None:
312
+ contained = [c for c in remaining if _is_service_window_contained(c, invoice.service_start_date, invoice.service_end_date)]
313
+ if contained:
314
+ remaining = contained
315
+ else:
316
+ # Candidates existed, but all were filtered out by this rule
317
+ return None, MatchExceptionRecord(
318
+ exception_type="cannot_match_no_contract",
319
+ exception_detail={
320
+ "reason": "all_candidates_filtered_out_by_service_period_containment",
321
+ "invoice_service_start_date": str(invoice.service_start_date),
322
+ "invoice_service_end_date": str(invoice.service_end_date),
323
+ "considered_contract_ids": [c.id for c in remaining],
324
+ },
325
+ )
326
+
327
+ # Tiebreaker 2: rate-card best fit via RapidFuzz
328
+ # Query uses joined canonical invoice descriptions.
329
+ joined_desc = _joined_invoice_descriptions(inv_lines)
330
+ if joined_desc:
331
+ scored_candidates: list[dict[str, Any]] = []
332
+ for c in remaining:
333
+ if not c.rate_card_items:
334
+ continue
335
+ item_names = [
336
+ (item.description_canonical or canonicalise_identifier(item.description) or "")
337
+ for item in c.rate_card_items
338
+ ]
339
+ item_names = [n for n in item_names if n]
340
+ if not item_names:
341
+ continue
342
+ scored_candidates.append({"record_id": c.id, "names": item_names})
343
+
344
+ if scored_candidates:
345
+ # Contracts without rate card items cannot participate; exclude them now
346
+ # so they don't survive to tiebreaker 3 ahead of contracts that do.
347
+ scored_ids = {x["record_id"] for x in scored_candidates}
348
+ remaining = [c for c in remaining if c.id in scored_ids]
349
+
350
+ # Evaluate each contract individually: keep only those whose rate card
351
+ # descriptions yield decision='matched' against the joined invoice text.
352
+ matched_ids = {
353
+ x["record_id"]
354
+ for x in scored_candidates
355
+ if rapidfuzz_best_match(
356
+ joined_desc,
357
+ [x],
358
+ high_threshold=cfg.high_threshold,
359
+ medium_threshold=cfg.medium_threshold,
360
+ score_cutoff=cfg.score_cutoff,
361
+ )["decision"] == "matched"
362
+ }
363
+ if matched_ids:
364
+ remaining = [c for c in remaining if c.id in matched_ids]
365
+ else:
366
+ return None, MatchExceptionRecord(
367
+ exception_type="cannot_match_no_contract",
368
+ exception_detail={
369
+ "reason": "rate_card_best_fit_filtered_all_candidates",
370
+ "joined_invoice_descriptions": joined_desc,
371
+ "considered_contract_ids": [c.id for c in remaining],
372
+ },
373
+ )
374
+
375
+ # Tiebreaker 3: newest created_at DESC, id DESC
376
+ remaining.sort(
377
+ key=lambda c: (
378
+ c.created_at or datetime.min,
379
+ c.id,
380
+ ),
381
+ reverse=True,
382
+ )
383
+
384
+ if len(remaining) == 1:
385
+ return remaining[0], None
386
+
387
+ if len(remaining) == 0:
388
+ return None, MatchExceptionRecord(
389
+ exception_type="cannot_match_no_contract",
390
+ exception_detail={
391
+ "reason": "all_candidates_removed_by_tiebreakers",
392
+ },
393
+ )
394
+
395
+ return None, MatchExceptionRecord(
396
+ exception_type="ambiguous_active_contract",
397
+ exception_detail={
398
+ "reason": "multiple_contracts_remain_after_tiebreakers",
399
+ "surviving_contract_ids": [c.id for c in remaining],
400
+ },
401
+ )
402
+
403
+
404
+ # ============================================================
405
+ # Config builder
406
+ # ============================================================
407
+ def _cfg_from_contract_dict(d: dict) -> ContractConfig:
408
+ def _d(key: str, default: str) -> Decimal:
409
+ return Decimal(str(d.get(key, default)))
410
+ def _f(key: str, default: float) -> float:
411
+ return float(d.get(key, default))
412
+ def _i(key: str, default: int) -> int:
413
+ return int(d.get(key, default))
414
+ return ContractConfig(
415
+ high_threshold=_f("contract_fuzzy_high_threshold", 90.0),
416
+ medium_threshold=_f("contract_fuzzy_medium_threshold", 80.0),
417
+ score_cutoff=_f("contract_fuzzy_score_cutoff", 80.0),
418
+ price_goods_green=_d("contract_price_goods_green", "5"),
419
+ price_goods_amber=_d("contract_price_goods_amber", "10"),
420
+ price_services_green=_d("contract_price_services_green", "10"),
421
+ price_services_amber=_d("contract_price_services_amber", "15"),
422
+ precision=_i("contract_precision", 28),
423
+ )
424
+
425
+
426
+ # ============================================================
427
+ # Main contract match core
428
+ # ============================================================
429
+ def run_contract_match_core(
430
+ invoice: ContractInvoiceHeader,
431
+ inv_lines: list[InvoiceLine],
432
+ contracts: list[ContractRecord],
433
+ *,
434
+ matching_configs: Optional[dict] = None,
435
+ ) -> ContractMatchResult:
436
+ """
437
+ Contract match used when po_number_canonical is NULL.
438
+
439
+ Returns:
440
+ - a stub exception result if no unique contract can be selected
441
+ - otherwise runs billing-period, rate-card, and budget checks
442
+
443
+ Note:
444
+ - cumulative_billed_amount is NOT incremented here
445
+ - this is matching-only, not approval
446
+ """
447
+ cfg = _cfg_from_contract_dict(matching_configs or {})
448
+ getcontext().prec = cfg.precision
449
+
450
+ result = ContractMatchResult(
451
+ lines_total=len(inv_lines),
452
+ documents_status="pending_approval",
453
+ match_type="contract",
454
+ )
455
+ result.advisory_flags = list(invoice.advisory_flags)
456
+
457
+ if invoice.resolved_vendor_id is None:
458
+ return _contract_stub_result(
459
+ invoice,
460
+ inv_lines,
461
+ "cannot_match_no_vendor",
462
+ {
463
+ "reason": "resolved_vendor_id_is_required",
464
+ "tenant_id": invoice.tenant_id,
465
+ "entity_id": invoice.entity_id,
466
+ },
467
+ )
468
+
469
+ selected_contract, stub_exc = select_contract_candidate(invoice, inv_lines, contracts, cfg)
470
+ if stub_exc is not None or selected_contract is None:
471
+ # contract_not_found / cannot_match_no_contract / ambiguous_active_contract
472
+ result.header_exceptions.append(stub_exc or MatchExceptionRecord(
473
+ exception_type="cannot_match_no_contract",
474
+ exception_detail={"reason": "no_selected_contract"},
475
+ ))
476
+ result.overall_status = "exception"
477
+ result.match_zone = "red"
478
+ result.lines_in_exception = len(inv_lines)
479
+ return result
480
+
481
+ result.selected_contract_id = selected_contract.id
482
+
483
+ # ---------------------------------------------------------
484
+ # Contract checks
485
+ # ---------------------------------------------------------
486
+ # 1) Billing period missing
487
+ if invoice.service_start_date is None or invoice.service_end_date is None:
488
+ result.match_exceptions.append(
489
+ MatchExceptionRecord(
490
+ exception_type="billing_period_unverified",
491
+ exception_detail={
492
+ "reason": "service_start_date_or_service_end_date_missing",
493
+ "service_start_date": str(invoice.service_start_date) if invoice.service_start_date else None,
494
+ "service_end_date": str(invoice.service_end_date) if invoice.service_end_date else None,
495
+ },
496
+ )
497
+ )
498
+
499
+ # 2) Billing period outside contract
500
+ if invoice.service_start_date is not None and invoice.service_end_date is not None:
501
+ if not _is_service_window_contained(selected_contract, invoice.service_start_date, invoice.service_end_date):
502
+ result.match_exceptions.append(
503
+ MatchExceptionRecord(
504
+ exception_type="billing_period_outside_contract",
505
+ exception_detail={
506
+ "reason": "invoice_service_window_not_contained_in_contract",
507
+ "service_start_date": str(invoice.service_start_date),
508
+ "service_end_date": str(invoice.service_end_date),
509
+ "contract_effective_from": str(selected_contract.effective_from) if selected_contract.effective_from else None,
510
+ "contract_effective_to": str(selected_contract.effective_to) if selected_contract.effective_to else None,
511
+ },
512
+ )
513
+ )
514
+
515
+ # 3) Rate card exists
516
+ if not selected_contract.rate_card_items:
517
+ result.match_exceptions.append(
518
+ MatchExceptionRecord(
519
+ exception_type="no_rate_card_configured",
520
+ exception_detail={
521
+ "reason": "contract_has_no_rate_card_items",
522
+ "contract_id": selected_contract.id,
523
+ },
524
+ )
525
+ )
526
+ result.overall_status = "exception"
527
+ result.match_zone = "red"
528
+ result.lines_in_exception = len(inv_lines)
529
+ return result
530
+
531
+ # 4) Per-line rate check
532
+ rate_card_candidates = [
533
+ {
534
+ "record_id": item.id,
535
+ "names": [item.description_canonical or canonicalise_identifier(item.description) or ""],
536
+ }
537
+ for item in selected_contract.rate_card_items
538
+ if (item.description_canonical or item.description)
539
+ ]
540
+
541
+ line_matched_count = 0
542
+ line_exception_count = 0
543
+
544
+ for inv_line in inv_lines:
545
+ query = inv_line.description_canonical or canonicalise_identifier(inv_line.description) or ""
546
+ if not query:
547
+ result.match_exceptions.append(
548
+ MatchExceptionRecord(
549
+ exception_type="no_rate_card_match",
550
+ line_id=inv_line.id,
551
+ exception_detail={
552
+ "reason": "invoice_line_description_missing",
553
+ "invoice_line_id": inv_line.id,
554
+ },
555
+ )
556
+ )
557
+ line_exception_count += 1
558
+ continue
559
+
560
+ rf = rapidfuzz_best_match(
561
+ query=query,
562
+ candidates=rate_card_candidates,
563
+ high_threshold=cfg.high_threshold,
564
+ medium_threshold=cfg.medium_threshold,
565
+ score_cutoff=cfg.score_cutoff,
566
+ )
567
+
568
+ if rf["decision"] not in {"matched", "suggestion"} or rf["record_id"] is None:
569
+ result.match_exceptions.append(
570
+ MatchExceptionRecord(
571
+ exception_type="no_rate_card_match",
572
+ line_id=inv_line.id,
573
+ exception_detail={
574
+ "reason": "rate_card_name_match_failed",
575
+ "invoice_line_id": inv_line.id,
576
+ "query": query,
577
+ },
578
+ )
579
+ )
580
+ line_exception_count += 1
581
+ continue
582
+
583
+ rate_item = next((x for x in selected_contract.rate_card_items if x.id == rf["record_id"]), None)
584
+ if rate_item is None:
585
+ result.match_exceptions.append(
586
+ MatchExceptionRecord(
587
+ exception_type="no_rate_card_match",
588
+ line_id=inv_line.id,
589
+ exception_detail={
590
+ "reason": "matched_rate_card_item_not_found",
591
+ "invoice_line_id": inv_line.id,
592
+ "matched_rate_card_item_id": rf["record_id"],
593
+ },
594
+ )
595
+ )
596
+ line_exception_count += 1
597
+ continue
598
+
599
+ # Compare unit price if present
600
+ if inv_line.unit_price is None:
601
+ if "rate_card_match_no_price_compare" not in result.advisory_flags:
602
+ result.advisory_flags.append("rate_card_match_no_price_compare")
603
+ result.match_line_results.append(
604
+ ContractLineResult(
605
+ invoice_line_id=inv_line.id,
606
+ rate_card_item_id=rate_item.id,
607
+ line_status="matched",
608
+ line_zone="green",
609
+ price_var_pct=None,
610
+ allocated_inv_amount=inv_line.amount,
611
+ allocated_rate_amount=rate_item.unit_price,
612
+ )
613
+ )
614
+ line_matched_count += 1
615
+ continue
616
+
617
+ if rate_item.unit_price is None:
618
+ # Name match succeeded; price comparison is impossible. Treat as an
619
+ # advisory (not a hard exception) because the match itself is valid.
620
+ if "rate_card_match_no_price_compare" not in result.advisory_flags:
621
+ result.advisory_flags.append("rate_card_match_no_price_compare")
622
+ result.match_line_results.append(
623
+ ContractLineResult(
624
+ invoice_line_id=inv_line.id,
625
+ rate_card_item_id=rate_item.id,
626
+ line_status="matched",
627
+ line_zone="green",
628
+ price_var_pct=None,
629
+ allocated_inv_amount=inv_line.amount,
630
+ allocated_rate_amount=None,
631
+ )
632
+ )
633
+ line_matched_count += 1
634
+ continue
635
+
636
+ p_var = price_variance_pct(inv_line.unit_price, rate_item.unit_price)
637
+ zone = _line_zone_from_price(p_var, rate_item.item_type, cfg)
638
+
639
+ if zone == "red":
640
+ result.match_exceptions.append(
641
+ MatchExceptionRecord(
642
+ exception_type="rate_card_price_mismatch",
643
+ line_id=inv_line.id,
644
+ po_line_id=rate_item.id,
645
+ exception_detail={
646
+ "reason": "price_outside_tolerance",
647
+ "invoice_line_id": inv_line.id,
648
+ "rate_card_item_id": rate_item.id,
649
+ "invoice_unit_price": str(inv_line.unit_price),
650
+ "rate_card_unit_price": str(rate_item.unit_price),
651
+ "price_var_pct": str(p_var),
652
+ },
653
+ )
654
+ )
655
+ result.match_line_results.append(
656
+ ContractLineResult(
657
+ invoice_line_id=inv_line.id,
658
+ rate_card_item_id=rate_item.id,
659
+ line_status="exception",
660
+ line_zone="red",
661
+ price_var_pct=p_var,
662
+ allocated_inv_amount=inv_line.amount,
663
+ allocated_rate_amount=rate_item.unit_price,
664
+ exception_type="rate_card_price_mismatch",
665
+ )
666
+ )
667
+ line_exception_count += 1
668
+ continue
669
+
670
+ result.match_line_results.append(
671
+ ContractLineResult(
672
+ invoice_line_id=inv_line.id,
673
+ rate_card_item_id=rate_item.id,
674
+ line_status="matched",
675
+ line_zone=zone,
676
+ price_var_pct=p_var,
677
+ allocated_inv_amount=inv_line.amount,
678
+ allocated_rate_amount=rate_item.unit_price,
679
+ )
680
+ )
681
+ line_matched_count += 1
682
+
683
+ result.lines_matched = line_matched_count
684
+ result.lines_in_exception = line_exception_count + (len(inv_lines) - line_matched_count - line_exception_count)
685
+
686
+ # ---------------------------------------------------------
687
+ # Budget ceiling
688
+ # ---------------------------------------------------------
689
+ inv_currency = norm_currency(invoice.currency)
690
+ contract_currency = norm_currency(selected_contract.currency)
691
+
692
+ if contract_currency != inv_currency:
693
+ result.match_exceptions.append(
694
+ MatchExceptionRecord(
695
+ exception_type="contract_currency_mismatch",
696
+ exception_detail={
697
+ "reason": "contract_currency_changed_or_mismatch",
698
+ "invoice_currency": inv_currency,
699
+ "contract_currency": contract_currency,
700
+ "contract_id": selected_contract.id,
701
+ },
702
+ )
703
+ )
704
+ result.overall_status = "exception"
705
+ result.match_zone = "red"
706
+ return result
707
+
708
+ if invoice.total_amount is not None:
709
+ cumulative = selected_contract.cumulative_billed_amount or D0
710
+ total_value = selected_contract.total_value
711
+ if total_value is not None and (cumulative + invoice.total_amount) > total_value:
712
+ result.match_exceptions.append(
713
+ MatchExceptionRecord(
714
+ exception_type="contract_budget_exceeded",
715
+ exception_detail={
716
+ "reason": "budget_ceiling_breached",
717
+ "contract_id": selected_contract.id,
718
+ "invoice_total_amount": str(invoice.total_amount),
719
+ "cumulative_billed_amount": str(cumulative),
720
+ "contract_total_value": str(total_value),
721
+ },
722
+ )
723
+ )
724
+ result.overall_status = "exception"
725
+ result.match_zone = "red"
726
+ return result
727
+
728
+ # ---------------------------------------------------------
729
+ # Overall status / zone
730
+ # ---------------------------------------------------------
731
+ if any(exc.exception_type in {
732
+ "billing_period_unverified",
733
+ "billing_period_outside_contract",
734
+ "no_rate_card_configured",
735
+ "no_rate_card_match",
736
+ "rate_card_price_mismatch",
737
+ "contract_budget_exceeded",
738
+ "contract_currency_mismatch",
739
+ } for exc in result.match_exceptions):
740
+ # If any hard contract exception exists, keep it red.
741
+ if result.lines_matched > 0 and result.lines_in_exception > 0:
742
+ result.overall_status = "partial_match"
743
+ else:
744
+ result.overall_status = "exception"
745
+ result.match_zone = "red"
746
+ else:
747
+ if result.lines_matched == result.lines_total and result.lines_in_exception == 0:
748
+ result.overall_status = "matched"
749
+ result.match_zone = "amber" if any(r.line_zone == "amber" for r in result.match_line_results) else "green"
750
+ elif result.lines_matched > 0:
751
+ result.overall_status = "partial_match"
752
+ result.match_zone = "amber" if any(r.line_zone == "amber" for r in result.match_line_results) else "green"
753
+ else:
754
+ result.overall_status = "exception"
755
+ result.match_zone = "red"
756
+
757
+ return result
src/Matching Functions/invoice_pipeline.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Top-level invoice processing pipeline.
3
+
4
+ Flow: validation → routing → matching → final result.
5
+
6
+ No business logic lives here. Each step delegates entirely to its own module:
7
+ - Validation : Invoice Validation Functions/orchestrator.py
8
+ - Routing : match_router.py
9
+ - 2-way match : two_way_match.py
10
+ - 3-way match : three_way_match.py (layers on top of 2-way result)
11
+ - Contract : contract_match.py
12
+
13
+ Caller responsibilities (not enforced here):
14
+ - Acquire the advisory lock before calling (SDD §3.1).
15
+ - Pre-fetch po, po_lines, gr_rows from the database.
16
+ - Construct matching_invoice (InvoiceHeader) and matching_inv_lines.
17
+ - Construct contract_invoice (ContractInvoiceHeader) when contract match is possible.
18
+ - Handle prior-match supersession in the database after this call returns (SDD §3.5).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import importlib.util
24
+ import json
25
+ import os
26
+ from typing import Any, Optional
27
+
28
+ # ============================================================
29
+ # Load validation orchestrator by path
30
+ # (the directory name contains spaces, so importlib is required)
31
+ # ============================================================
32
+
33
+ _HERE = os.path.dirname(os.path.abspath(__file__))
34
+ _VALIDATION_DIR = os.path.normpath(os.path.join(_HERE, "..", "Invoice Validation Functions"))
35
+
36
+
37
+ def _load_module_by_path(filepath: str, module_name: str):
38
+ spec = importlib.util.spec_from_file_location(module_name, filepath)
39
+ mod = importlib.util.module_from_spec(spec)
40
+ spec.loader.exec_module(mod)
41
+ return mod
42
+
43
+
44
+ _val_orch = _load_module_by_path(
45
+ os.path.join(_VALIDATION_DIR, "orchestrator.py"),
46
+ "_validation_orchestrator",
47
+ )
48
+ _run_validation_pipeline = _val_orch.run_invoice_validation_pipeline
49
+
50
+
51
+ # ============================================================
52
+ # Matching layer imports
53
+ # ============================================================
54
+
55
+ from match_router import (
56
+ MatchPreconditionError,
57
+ RouteDecision,
58
+ RoutingDocument,
59
+ RoutingExtractedFields,
60
+ route_invoice,
61
+ )
62
+ from two_way_match import (
63
+ InvoiceHeader,
64
+ InvoiceLine,
65
+ MatchExceptionRecord,
66
+ POHeader,
67
+ POLine,
68
+ run_two_way_match_core,
69
+ )
70
+ from three_way_match import GRLineItem, run_three_way_match
71
+ from contract_match import (
72
+ ContractInvoiceHeader,
73
+ ContractMatchResult,
74
+ run_contract_match_core,
75
+ )
76
+
77
+
78
+ # ============================================================
79
+ # Config loader
80
+ # ============================================================
81
+
82
+ def _load_matching_configs(path: Optional[str] = None) -> dict:
83
+ if path is None:
84
+ path = os.path.join(_HERE, "matching_configs.json")
85
+ with open(path, encoding="utf-8") as fh:
86
+ return json.load(fh)
87
+
88
+
89
+ # ============================================================
90
+ # Internal helpers
91
+ # ============================================================
92
+
93
+ def _stub_missing_contract_invoice(inv_lines: list[InvoiceLine]) -> ContractMatchResult:
94
+ """Returned when contract route is selected but contract_invoice was not provided."""
95
+ res = ContractMatchResult(
96
+ match_type="contract",
97
+ overall_status="exception",
98
+ match_zone="red",
99
+ documents_status="pending_approval",
100
+ lines_total=len(inv_lines),
101
+ lines_matched=0,
102
+ lines_in_exception=len(inv_lines),
103
+ )
104
+ res.header_exceptions.append(
105
+ MatchExceptionRecord(
106
+ exception_type="cannot_match_missing_contract_invoice",
107
+ exception_detail={"reason": "contract_invoice_not_provided_to_pipeline"},
108
+ )
109
+ )
110
+ return res
111
+
112
+
113
+ def _collect_match_flags(match_result: Any) -> tuple[list, list, list]:
114
+ """Extract risk_flags, advisory_flags, skipped_steps from a match result."""
115
+ if match_result is None:
116
+ return [], [], []
117
+ return (
118
+ list(getattr(match_result, "risk_flags", None) or []),
119
+ list(getattr(match_result, "advisory_flags", None) or []),
120
+ list(getattr(match_result, "skipped_steps", None) or []),
121
+ )
122
+
123
+
124
+ def _compute_final_status(validation_risk_flags: list, match_result: Any) -> str:
125
+ """
126
+ Derive a single final status from both validation and match outcomes.
127
+ Any validation risk flag is treated as an exception regardless of match result.
128
+ """
129
+ if validation_risk_flags:
130
+ return "exception"
131
+ if match_result is None:
132
+ return "exception"
133
+ return getattr(match_result, "overall_status", "exception")
134
+
135
+
136
+ # ============================================================
137
+ # Top-level pipeline
138
+ # ============================================================
139
+
140
+ def run_invoice_pipeline(
141
+ *,
142
+ # --- Validation inputs ---
143
+ document,
144
+ extracted_fields,
145
+ vendors,
146
+ vendor_bank_accounts,
147
+ vendor_tax_ids,
148
+ entity,
149
+ line_items,
150
+ purchase_orders,
151
+ contracts,
152
+ tax_master_rows,
153
+ currency_seed_map,
154
+ platform_configs: Optional[dict] = None,
155
+ route_contexts=None,
156
+ actor_id=None,
157
+ # --- Matching inputs ---
158
+ matching_invoice: InvoiceHeader,
159
+ matching_inv_lines: list[InvoiceLine],
160
+ contract_invoice: Optional[ContractInvoiceHeader] = None,
161
+ po: Optional[POHeader] = None,
162
+ po_lines: Optional[list[POLine]] = None,
163
+ gr_rows: Optional[list[GRLineItem]] = None,
164
+ matching_configs: Optional[dict] = None,
165
+ ) -> dict[str, Any]:
166
+ """
167
+ Run the full invoice processing pipeline and return a single combined result.
168
+
169
+ Parameters
170
+ ----------
171
+ document : Invoice document object (.id, .tenant_id, .entity_id,
172
+ .status, .doc_type required for routing).
173
+ extracted_fields : VLM-extracted fields (.po_number_canonical used for routing).
174
+ vendors : Vendor records for vendor validation.
175
+ vendor_bank_accounts : Vendor bank account records.
176
+ vendor_tax_ids : Vendor tax-ID records.
177
+ entity : Routing entity (.country_code, .vat_id, .region_code).
178
+ line_items : Invoice line-item records (for validation).
179
+ purchase_orders : Purchase-order records (for payment-terms validation).
180
+ contracts : Contract records (for payment-terms validation and contract match).
181
+ tax_master_rows : Tax-master records.
182
+ currency_seed_map : Dict mapping ISO currency code → minor-unit row.
183
+ platform_configs : Validation config dict; loaded from platform_configs.json if None.
184
+ route_contexts : Route-context strings forwarded to Vendor Validation.
185
+ actor_id : Actor identifier forwarded to Vendor Validation for audit.
186
+ matching_invoice : InvoiceHeader for 2-way / 3-way matching.
187
+ matching_inv_lines : Invoice lines for all match types.
188
+ contract_invoice : ContractInvoiceHeader for contract matching (optional).
189
+ po : Pre-fetched POHeader; None if no PO number on the invoice.
190
+ po_lines : Pre-fetched PO lines; None if no PO.
191
+ gr_rows : Pre-fetched GR rows; None or [] triggers 2-way instead of 3-way.
192
+ matching_configs : Matching config dict; loaded from matching_configs.json if None.
193
+
194
+ Returns
195
+ -------
196
+ dict with keys:
197
+ validation_result — raw dict from the validation pipeline
198
+ route — 'two_way' | 'three_way' | 'contract' | 'credit_note' |
199
+ 'stub' | 'precondition_error'
200
+ match_result — TwoWayMatchResult | ContractMatchResult | None
201
+ risk_flags — combined validation + matching risk flags, in execution order
202
+ advisory_flags — combined, in execution order
203
+ skipped_steps — combined, in execution order
204
+ ops_alerts — operational alerts from the validation layer
205
+ resolved_vendor_id — from vendor validation
206
+ final_status — 'matched' | 'partial_match' | 'exception'
207
+ """
208
+
209
+ # ------------------------------------------------------------------
210
+ # Step 1 — Validation
211
+ # ------------------------------------------------------------------
212
+ validation_result = _run_validation_pipeline(
213
+ document=document,
214
+ extracted_fields=extracted_fields,
215
+ vendors=vendors,
216
+ vendor_bank_accounts=vendor_bank_accounts,
217
+ vendor_tax_ids=vendor_tax_ids,
218
+ entity=entity,
219
+ line_items=line_items,
220
+ purchase_orders=purchase_orders,
221
+ contracts=contracts,
222
+ tax_master_rows=tax_master_rows,
223
+ currency_seed_map=currency_seed_map,
224
+ platform_configs=platform_configs,
225
+ route_contexts=route_contexts,
226
+ actor_id=actor_id,
227
+ )
228
+
229
+ resolved_vendor_id = validation_result.get("resolved_vendor_id")
230
+ risk_flags: list = list(validation_result.get("risk_flags") or [])
231
+ advisory_flags: list = list(validation_result.get("advisory_flags") or [])
232
+ skipped_steps: list = list(validation_result.get("skipped_steps") or [])
233
+
234
+ # ------------------------------------------------------------------
235
+ # Step 2 — Routing
236
+ # ------------------------------------------------------------------
237
+ if matching_configs is None:
238
+ matching_configs = _load_matching_configs()
239
+
240
+ routing_doc = RoutingDocument(
241
+ id=getattr(document, "id", None),
242
+ tenant_id=getattr(document, "tenant_id", None),
243
+ entity_id=getattr(document, "entity_id", None),
244
+ status=getattr(document, "status", "validated"),
245
+ doc_type=getattr(document, "doc_type", "invoice"),
246
+ )
247
+ routing_ef = RoutingExtractedFields(
248
+ po_number_canonical=getattr(extracted_fields, "po_number_canonical", None),
249
+ resolved_vendor_id=resolved_vendor_id,
250
+ )
251
+
252
+ try:
253
+ decision: RouteDecision = route_invoice(
254
+ document=routing_doc,
255
+ ef=routing_ef,
256
+ inv_lines=matching_inv_lines,
257
+ po=po,
258
+ po_lines=po_lines,
259
+ gr_rows=gr_rows,
260
+ )
261
+ except MatchPreconditionError as exc:
262
+ return {
263
+ "validation_result": validation_result,
264
+ "route": "precondition_error",
265
+ "match_result": None,
266
+ "risk_flags": risk_flags,
267
+ "advisory_flags": advisory_flags,
268
+ "skipped_steps": skipped_steps,
269
+ "ops_alerts": list(validation_result.get("ops_alerts") or []),
270
+ "resolved_vendor_id": resolved_vendor_id,
271
+ "final_status": "exception",
272
+ "error": str(exc),
273
+ }
274
+
275
+ route = decision.route
276
+
277
+ # ------------------------------------------------------------------
278
+ # Step 3 — Matching
279
+ # ------------------------------------------------------------------
280
+ match_result: Any = None
281
+
282
+ if route == "stub":
283
+ match_result = decision.stub_result
284
+
285
+ elif route == "two_way":
286
+ match_result = run_two_way_match_core(
287
+ matching_invoice,
288
+ matching_inv_lines,
289
+ decision.po,
290
+ decision.po_lines or [],
291
+ matching_configs=matching_configs,
292
+ )
293
+
294
+ elif route == "three_way":
295
+ two_way_result = run_two_way_match_core(
296
+ matching_invoice,
297
+ matching_inv_lines,
298
+ decision.po,
299
+ decision.po_lines or [],
300
+ matching_configs=matching_configs,
301
+ )
302
+ match_result = run_three_way_match(
303
+ two_way_result,
304
+ matching_invoice,
305
+ matching_inv_lines,
306
+ decision.po_lines or [],
307
+ decision.gr_rows or [],
308
+ matching_configs=matching_configs,
309
+ )
310
+
311
+ elif route == "contract":
312
+ if contract_invoice is None:
313
+ match_result = _stub_missing_contract_invoice(matching_inv_lines)
314
+ else:
315
+ match_result = run_contract_match_core(
316
+ contract_invoice,
317
+ matching_inv_lines,
318
+ list(contracts),
319
+ matching_configs=matching_configs,
320
+ )
321
+
322
+ # ------------------------------------------------------------------
323
+ # Step 4 — Combine flags and return
324
+ # ------------------------------------------------------------------
325
+ m_risk, m_advisory, m_skipped = _collect_match_flags(match_result)
326
+ risk_flags.extend(m_risk)
327
+ advisory_flags.extend(m_advisory)
328
+ skipped_steps.extend(m_skipped)
329
+
330
+ return {
331
+ "validation_result": validation_result,
332
+ "route": route,
333
+ "match_result": match_result,
334
+ "risk_flags": risk_flags,
335
+ "advisory_flags": advisory_flags,
336
+ "skipped_steps": skipped_steps,
337
+ "ops_alerts": list(validation_result.get("ops_alerts") or []),
338
+ "resolved_vendor_id": resolved_vendor_id,
339
+ "final_status": _compute_final_status(
340
+ validation_result.get("risk_flags") or [], match_result
341
+ ),
342
+ }
src/Matching Functions/match_router.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Optional, Union
5
+
6
+ from two_way_match import (
7
+ InvoiceLine,
8
+ MatchExceptionRecord,
9
+ POHeader,
10
+ POLine,
11
+ TwoWayMatchResult,
12
+ )
13
+ from three_way_match import GRLineItem
14
+ from contract_match import ContractMatchResult
15
+
16
+
17
+ # ============================================================
18
+ # Precondition violation
19
+ # ============================================================
20
+
21
+ class MatchPreconditionError(Exception):
22
+ """Raised when document.status is not 'validated' or 'matching' (SDD §3.1)."""
23
+
24
+
25
+ # ============================================================
26
+ # Routing input models
27
+ # ============================================================
28
+
29
+ @dataclass(frozen=True)
30
+ class RoutingDocument:
31
+ id: int
32
+ tenant_id: int
33
+ entity_id: int
34
+ status: str # 'validated' | 'matching' — enforced by route_invoice
35
+ doc_type: str # 'invoice' | 'credit_note' | ...
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class RoutingExtractedFields:
40
+ po_number_canonical: Optional[str]
41
+ resolved_vendor_id: Optional[int]
42
+
43
+
44
+ # ============================================================
45
+ # Routing decision
46
+ # ============================================================
47
+
48
+ MatchResult = Union[TwoWayMatchResult, ContractMatchResult]
49
+
50
+
51
+ @dataclass
52
+ class RouteDecision:
53
+ route: str # 'two_way' | 'three_way' | 'contract' | 'stub'
54
+ po: Optional[POHeader] = None # populated for two_way / three_way
55
+ po_lines: Optional[list[POLine]] = None
56
+ gr_rows: Optional[list[GRLineItem]] = None # populated for three_way
57
+ stub_result: Optional[MatchResult] = None # populated when route='stub'
58
+
59
+
60
+ # ============================================================
61
+ # Stub builders (SDD §3.4)
62
+ # ============================================================
63
+
64
+ def _two_way_stub(
65
+ inv_lines: list[InvoiceLine],
66
+ exception_type: str,
67
+ exception_detail: dict[str, Any],
68
+ ) -> TwoWayMatchResult:
69
+ res = TwoWayMatchResult(
70
+ match_type="two_way",
71
+ overall_status="exception",
72
+ match_zone="red",
73
+ documents_status="pending_approval",
74
+ lines_total=len(inv_lines),
75
+ lines_matched=0,
76
+ lines_in_exception=len(inv_lines),
77
+ )
78
+ res.header_exceptions.append(
79
+ MatchExceptionRecord(
80
+ exception_type=exception_type,
81
+ exception_detail=exception_detail,
82
+ )
83
+ )
84
+ return res
85
+
86
+
87
+ def _contract_stub(
88
+ inv_lines: list[InvoiceLine],
89
+ exception_type: str,
90
+ exception_detail: dict[str, Any],
91
+ ) -> ContractMatchResult:
92
+ res = ContractMatchResult(
93
+ match_type="contract",
94
+ overall_status="exception",
95
+ match_zone="red",
96
+ documents_status="pending_approval",
97
+ lines_total=len(inv_lines),
98
+ lines_matched=0,
99
+ lines_in_exception=len(inv_lines),
100
+ )
101
+ res.header_exceptions.append(
102
+ MatchExceptionRecord(
103
+ exception_type=exception_type,
104
+ exception_detail=exception_detail,
105
+ )
106
+ )
107
+ return res
108
+
109
+
110
+ # ============================================================
111
+ # Status sets
112
+ # ============================================================
113
+
114
+ _VALID_ENTRY_STATUSES = {"validated", "matching"}
115
+ _PO_ACTIVE_STATUSES = {"open", "partial"}
116
+ _PO_LINE_LIVE_STATUSES = {"open", "fully_invoiced"}
117
+ _PO_LINE_DEAD_STATUSES = {"closed", "cancelled"}
118
+
119
+
120
+ # ============================================================
121
+ # Decision tree (SDD §3.3)
122
+ # ============================================================
123
+
124
+ def route_invoice(
125
+ document: RoutingDocument,
126
+ ef: RoutingExtractedFields,
127
+ inv_lines: list[InvoiceLine],
128
+ po: Optional[POHeader],
129
+ po_lines: Optional[list[POLine]],
130
+ gr_rows: Optional[list[GRLineItem]],
131
+ ) -> RouteDecision:
132
+ """
133
+ Deterministic match-type routing (SDD §3.3). First match wins.
134
+
135
+ Caller responsibilities:
136
+ - Acquire the advisory lock before calling (§3.1).
137
+ - Pre-fetch po / po_lines from the DB (pass None if po_number_canonical
138
+ is NULL or the PO row was not found).
139
+ - Pre-fetch gr_rows (pass empty list or None if no GR rows exist for the PO).
140
+ - After receiving RouteDecision, handle prior-match supersession (§3.5)
141
+ before dispatching to the match function.
142
+
143
+ Raises:
144
+ MatchPreconditionError — document.status not in {'validated', 'matching'} (§3.1).
145
+ """
146
+
147
+ # §3.1 — Entry precondition
148
+ if document.status not in _VALID_ENTRY_STATUSES:
149
+ raise MatchPreconditionError(
150
+ f"document {document.id} has status={document.status!r}; "
151
+ f"expected one of {sorted(_VALID_ENTRY_STATUSES)}"
152
+ )
153
+
154
+ # §3.3 rule 2 — PO-based routing
155
+ if ef.po_number_canonical is not None:
156
+ if po is None:
157
+ return RouteDecision(
158
+ route="stub",
159
+ stub_result=_two_way_stub(
160
+ inv_lines,
161
+ "po_not_found",
162
+ {
163
+ "reason": "no_purchase_order_matched_canonical",
164
+ "tenant_id": document.tenant_id,
165
+ "entity_id": document.entity_id,
166
+ "po_number_canonical": ef.po_number_canonical,
167
+ },
168
+ ),
169
+ )
170
+
171
+ if po.status == "cancelled":
172
+ return RouteDecision(
173
+ route="stub",
174
+ stub_result=_two_way_stub(
175
+ inv_lines,
176
+ "po_cancelled",
177
+ {
178
+ "reason": "purchase_order_is_cancelled",
179
+ "po_id": po.id,
180
+ "po_number_canonical": ef.po_number_canonical,
181
+ },
182
+ ),
183
+ )
184
+
185
+ lines: list[POLine] = po_lines or []
186
+
187
+ if po.status == "closed" and all(
188
+ pl.status in _PO_LINE_DEAD_STATUSES for pl in lines
189
+ ):
190
+ return RouteDecision(
191
+ route="stub",
192
+ stub_result=_two_way_stub(
193
+ inv_lines,
194
+ "po_closed",
195
+ {
196
+ "reason": "purchase_order_closed_all_lines_dead",
197
+ "po_id": po.id,
198
+ "po_number_canonical": ef.po_number_canonical,
199
+ "line_statuses": [pl.status for pl in lines],
200
+ },
201
+ ),
202
+ )
203
+
204
+ # §3.3 table: open/partial + every line closed/cancelled → po_all_lines_closed
205
+ if po.status in _PO_ACTIVE_STATUSES and all(
206
+ pl.status in _PO_LINE_DEAD_STATUSES for pl in lines
207
+ ):
208
+ return RouteDecision(
209
+ route="stub",
210
+ stub_result=_two_way_stub(
211
+ inv_lines,
212
+ "po_all_lines_closed",
213
+ {
214
+ "reason": "all_po_lines_closed_or_cancelled",
215
+ "po_id": po.id,
216
+ "po_number_canonical": ef.po_number_canonical,
217
+ "line_statuses": [pl.status for pl in lines],
218
+ },
219
+ ),
220
+ )
221
+
222
+ # §3.3: at least one live line (open or fully_invoiced) must exist to proceed.
223
+ # fully_invoiced lines reach matching (not stub) so credit-note reversals and
224
+ # po_ceiling_exhausted can be raised correctly by §4 / §5.
225
+ has_live_line = any(pl.status in _PO_LINE_LIVE_STATUSES for pl in lines)
226
+ if not has_live_line:
227
+ return RouteDecision(
228
+ route="stub",
229
+ stub_result=_two_way_stub(
230
+ inv_lines,
231
+ "po_all_lines_closed",
232
+ {
233
+ "reason": "no_live_po_lines_found",
234
+ "po_id": po.id,
235
+ "po_number_canonical": ef.po_number_canonical,
236
+ "line_statuses": [pl.status for pl in lines],
237
+ },
238
+ ),
239
+ )
240
+
241
+ # §3.3: any GR row (any inspection_status) → three-way.
242
+ # §5 adjudicates no_accepted_gr / inspection_rejected / timing_fraud internally.
243
+ if gr_rows:
244
+ return RouteDecision(
245
+ route="three_way",
246
+ po=po,
247
+ po_lines=lines,
248
+ gr_rows=gr_rows,
249
+ )
250
+
251
+ return RouteDecision(
252
+ route="two_way",
253
+ po=po,
254
+ po_lines=lines,
255
+ )
256
+
257
+ # §3.3 rule 3 — No PO, no vendor anchor
258
+ if ef.resolved_vendor_id is None:
259
+ return RouteDecision(
260
+ route="stub",
261
+ stub_result=_contract_stub(
262
+ inv_lines,
263
+ "cannot_match_no_vendor",
264
+ {
265
+ "reason": "resolved_vendor_id_is_null_and_no_po",
266
+ "tenant_id": document.tenant_id,
267
+ "entity_id": document.entity_id,
268
+ },
269
+ ),
270
+ )
271
+
272
+ # §3.3 rule 4 — No PO, vendor resolved → contract match
273
+ return RouteDecision(route="contract")
src/Matching Functions/matching_configs.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment_two_way": "Two-way / three-way invoice-PO matching. All percentage thresholds are in % units (e.g. 5 means 5%).",
3
+
4
+ "tier3_price_variance_threshold": 0.10,
5
+
6
+ "price_goods_green": 5.0,
7
+ "price_goods_amber": 10.0,
8
+
9
+ "price_services_green": 10.0,
10
+ "price_services_amber": 15.0,
11
+
12
+ "qty_goods_green": 2.0,
13
+ "qty_goods_amber": 4.0,
14
+
15
+ "qty_services_green": 3.0,
16
+ "qty_services_amber": 6.0,
17
+
18
+ "tier4_gl_group_max_lines": 100,
19
+
20
+ "fuzzy_high_threshold": 90.0,
21
+ "fuzzy_medium_threshold": 80.0,
22
+ "fuzzy_score_cutoff": 80.0,
23
+
24
+ "precision": 28,
25
+
26
+ "_comment_contract": "Contract match configuration. Keys are prefixed 'contract_' to allow independent tuning from two-way bands.",
27
+
28
+ "contract_fuzzy_high_threshold": 90.0,
29
+ "contract_fuzzy_medium_threshold": 80.0,
30
+ "contract_fuzzy_score_cutoff": 80.0,
31
+
32
+ "contract_price_goods_green": 5.0,
33
+ "contract_price_goods_amber": 10.0,
34
+
35
+ "contract_price_services_green": 10.0,
36
+ "contract_price_services_amber": 15.0,
37
+
38
+ "contract_precision": 28
39
+ }
src/Matching Functions/three_way_match.py ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from collections import defaultdict
3
+ from dataclasses import dataclass
4
+ from datetime import date
5
+ from decimal import Decimal
6
+ from typing import Any, Optional
7
+
8
+ from two_way_match import (
9
+ InvoiceHeader,
10
+ InvoiceLine,
11
+ MatchExceptionRecord,
12
+ POLine,
13
+ TwoWayConfig,
14
+ TwoWayMatchResult,
15
+ _cfg_from_dict,
16
+ _qty_bands,
17
+ to_decimal,
18
+ )
19
+
20
+ D0 = Decimal("0")
21
+
22
+ # ── New input dataclass ────────────────────────────────────────────────────────
23
+
24
+ @dataclass
25
+ class GRLineItem:
26
+ id: int
27
+ po_line_id: Optional[int]
28
+ gr_date: date # DATE column, no timezone
29
+ inspection_status: str # 'accepted'|'partial_accept'|'rejected'|'pending'|'returned'
30
+ quantity_accepted: Optional[Decimal]
31
+ quantity_received: Optional[Decimal]
32
+ deleted_at: Optional[Any] = None # non-None → soft-deleted; excluded everywhere
33
+
34
+
35
+ # ── Zone rank (adds 'critical' above 'red' for integrity signals) ──────────────
36
+
37
+ _ZONE_RANK: dict[str, int] = {"green": 0, "amber": 1, "red": 2, "critical": 3}
38
+
39
+
40
+ def _worse(a: Optional[str], b: str) -> str:
41
+ """Worst-wins comparator. critical > red > amber > green."""
42
+ return b if _ZONE_RANK.get(b, 0) > _ZONE_RANK.get(a or "green", 0) else (a or "green")
43
+
44
+
45
+ def _push_zone(result: TwoWayMatchResult, inv_line_id: Optional[int], zone: str) -> None:
46
+ """Update a specific matched line's zone and the overall match_zone, worst-wins."""
47
+ if inv_line_id is not None:
48
+ for mlr in result.match_line_results:
49
+ if mlr.invoice_line_id == inv_line_id:
50
+ mlr.line_zone = _worse(mlr.line_zone, zone)
51
+ result.match_zone = _worse(result.match_zone, zone)
52
+
53
+
54
+ # ── Private helpers ────────────────────────────────────────────────────────────
55
+
56
+ def _uninvoiced_qty(
57
+ po_line: POLine,
58
+ accepted_by_po: dict[int, list[GRLineItem]],
59
+ ) -> Decimal:
60
+ """
61
+ SUM(quantity_accepted for accepted/partial_accept GRs on po_line) − po_line.invoiced_qty.
62
+ Raises RuntimeError on IDP17: quantity_accepted IS NULL on an accepted row.
63
+ Callers must NOT fall back to quantity_received — that would silently mask the violation.
64
+ """
65
+ rows = accepted_by_po.get(po_line.id, [])
66
+ for g in rows:
67
+ if g.quantity_accepted is None:
68
+ raise RuntimeError(
69
+ f"IDP17 violation: GR line id={g.id} inspection_status={g.inspection_status!r} "
70
+ "has quantity_accepted IS NULL."
71
+ )
72
+ total_accepted = sum((g.quantity_accepted for g in rows), D0)
73
+ return total_accepted - (to_decimal(po_line.invoiced_qty) or D0)
74
+
75
+
76
+ def _band_verdict(
77
+ qty_over_pct: Decimal,
78
+ item_type: Optional[str],
79
+ cfg: TwoWayConfig,
80
+ ) -> Optional[str]:
81
+ """Return failing zone ('amber'|'red') if qty_over_pct exceeds tolerance, else None (pass)."""
82
+ qty_green, qty_amber = _qty_bands(item_type, cfg) # reuses existing 2-way helper
83
+ if qty_over_pct == D0 or qty_over_pct <= qty_green:
84
+ return None
85
+ return "amber" if qty_over_pct <= qty_amber else "red"
86
+
87
+
88
+ def _qty_check_single(
89
+ *,
90
+ inv_line: InvoiceLine,
91
+ po_line: POLine,
92
+ accepted_by_po: dict[int, list[GRLineItem]],
93
+ result: TwoWayMatchResult,
94
+ cfg: TwoWayConfig,
95
+ ) -> None:
96
+ """§5.3 quantity-over-received check for one Tier 1-3 (inv_line, po_line) direct match."""
97
+
98
+ # Pre-band guard 1: missing invoice qty — Cat 2 incomplete_fields already owns this flag
99
+ if inv_line.quantity is None:
100
+ result.skipped_steps.append({
101
+ "step": "qty_over_received",
102
+ "skip_reason": "inv_qty_null",
103
+ "line_id": inv_line.id,
104
+ "po_line_id": po_line.id,
105
+ })
106
+ return
107
+
108
+ uninvoiced = _uninvoiced_qty(po_line, accepted_by_po)
109
+
110
+ # Pre-band guard 2: negative uninvoiced → IDP17 forensic exception, zone red
111
+ if uninvoiced < D0:
112
+ result.match_exceptions.append(MatchExceptionRecord(
113
+ exception_type="qty_over_received",
114
+ line_id=inv_line.id,
115
+ po_line_id=po_line.id,
116
+ exception_detail={
117
+ "reason": "uninvoiced_accepted_qty_negative",
118
+ "uninvoiced_accepted_qty": str(uninvoiced),
119
+ },
120
+ ))
121
+ _push_zone(result, inv_line.id, "red")
122
+ result.overall_status = "exception"
123
+ return
124
+
125
+ inv_qty = inv_line.quantity
126
+
127
+ # Pre-band guard 3/4: uninvoiced == 0
128
+ if uninvoiced == D0:
129
+ if inv_qty > D0:
130
+ # All accepted GR qty already consumed by prior approved invoices
131
+ result.match_exceptions.append(MatchExceptionRecord(
132
+ exception_type="gr_fully_consumed",
133
+ line_id=inv_line.id,
134
+ po_line_id=po_line.id,
135
+ exception_detail={"reason": "gr_fully_consumed", "inv_qty": str(inv_qty)},
136
+ ))
137
+ _push_zone(result, inv_line.id, "red")
138
+ result.overall_status = "exception"
139
+ # inv_qty == 0 → noise line ($0 line), skip silently
140
+ return
141
+
142
+ # Band evaluation
143
+ qty_over_pct = max(D0, (inv_qty - uninvoiced) / uninvoiced * Decimal("100"))
144
+ zone = _band_verdict(qty_over_pct, po_line.item_type, cfg)
145
+ if zone:
146
+ result.match_exceptions.append(MatchExceptionRecord(
147
+ exception_type="qty_over_received",
148
+ line_id=inv_line.id,
149
+ po_line_id=po_line.id,
150
+ exception_detail={
151
+ "reason": "qty_over_received",
152
+ "qty_over_pct": str(qty_over_pct),
153
+ "inv_qty": str(inv_qty),
154
+ "uninvoiced_accepted_qty": str(uninvoiced),
155
+ },
156
+ ))
157
+ _push_zone(result, inv_line.id, zone)
158
+ result.overall_status = "exception"
159
+
160
+
161
+ def _qty_check_tier4_group(
162
+ *,
163
+ gl_account: Optional[str],
164
+ group_inv_lines: list[InvoiceLine],
165
+ group_po_lines: list[POLine],
166
+ accepted_by_po: dict[int, list[GRLineItem]],
167
+ accepted_gr: list[GRLineItem], # full accepted list — needed to detect no-GR vs consumed
168
+ result: TwoWayMatchResult,
169
+ cfg: TwoWayConfig,
170
+ ) -> None:
171
+ """
172
+ §5.6 aggregate GR quantity check for one Tier-4 GL group.
173
+ Partitions PO lines into goods / services subsets.
174
+ NULL-item-type PO lines are excluded from both (no GR obligation by default).
175
+ The quantity comparison is conservative: ALL inv-line qty (goods+services) vs
176
+ ONLY the goods-subset uninvoiced accepted qty.
177
+ """
178
+ po_goods = [pl for pl in group_po_lines if pl.item_type == "goods"]
179
+ po_services = [pl for pl in group_po_lines if pl.item_type == "services"]
180
+
181
+ if not po_goods:
182
+ return # 100 % services or all NULL item_type → no GR obligation for this group
183
+
184
+ goods_ids = {pl.id for pl in po_goods}
185
+
186
+ # IDP17 violations surface here via RuntimeError from _uninvoiced_qty
187
+ uninvoiced_goods = sum((_uninvoiced_qty(pl, accepted_by_po) for pl in po_goods), D0)
188
+
189
+ # Total invoice qty over the WHOLE group (conservative: includes services portion)
190
+ inv_qty_total = sum(
191
+ (il.quantity for il in group_inv_lines if il.quantity is not None), D0
192
+ )
193
+
194
+ inv_ids = [il.id for il in group_inv_lines]
195
+
196
+ base_detail: dict[str, Any] = {
197
+ "group_gl_account": gl_account,
198
+ "po_lines_goods": sorted(goods_ids),
199
+ "po_lines_services": [pl.id for pl in po_services],
200
+ "uninvoiced_accepted_qty_goods": str(uninvoiced_goods),
201
+ "inv_qty_total": str(inv_qty_total),
202
+ }
203
+
204
+ # Pre-band guard: negative uninvoiced_goods → IDP17 forensic
205
+ if uninvoiced_goods < D0:
206
+ result.match_exceptions.append(MatchExceptionRecord(
207
+ exception_type="qty_over_received",
208
+ exception_detail={**base_detail, "reason": "uninvoiced_accepted_qty_negative"},
209
+ ))
210
+ for il_id in inv_ids:
211
+ _push_zone(result, il_id, "red")
212
+ result.overall_status = "exception"
213
+ return
214
+
215
+ # Pre-band guard: uninvoiced_goods == 0
216
+ if uninvoiced_goods == D0:
217
+ # Distinguish "no accepted GRs for goods lines yet" from "fully consumed"
218
+ has_accepted_gr_for_goods = any(g.po_line_id in goods_ids for g in accepted_gr)
219
+ exc_type = "gr_fully_consumed" if has_accepted_gr_for_goods else "no_accepted_gr"
220
+ if inv_qty_total > D0:
221
+ result.match_exceptions.append(MatchExceptionRecord(
222
+ exception_type=exc_type,
223
+ exception_detail={**base_detail, "reason": exc_type},
224
+ ))
225
+ for il_id in inv_ids:
226
+ _push_zone(result, il_id, "red")
227
+ result.overall_status = "exception"
228
+ # inv_qty_total == 0 → noise group, skip silently
229
+ return
230
+
231
+ # Band evaluation (always uses "goods" tolerance for mixed groups — §5.6 conservative choice)
232
+ qty_over_pct = max(D0, (inv_qty_total - uninvoiced_goods) / uninvoiced_goods * Decimal("100"))
233
+ zone = _band_verdict(qty_over_pct, "goods", cfg)
234
+ if zone:
235
+ result.match_exceptions.append(MatchExceptionRecord(
236
+ exception_type="qty_over_received",
237
+ exception_detail={
238
+ **base_detail,
239
+ "subset_breakdown": {"qty_over_pct": str(qty_over_pct)},
240
+ },
241
+ ))
242
+ for il_id in inv_ids:
243
+ _push_zone(result, il_id, zone)
244
+ result.overall_status = "exception"
245
+
246
+
247
+ # ── Public entry point ─────────────────────────────────────────────────────────
248
+
249
+ def run_three_way_match(
250
+ two_way_result: TwoWayMatchResult,
251
+ invoice: InvoiceHeader, # must have invoice_date: Optional[date]
252
+ inv_lines: list[InvoiceLine],
253
+ po_lines: list[POLine],
254
+ gr_lines: list[GRLineItem],
255
+ *,
256
+ matching_configs: Optional[dict] = None,
257
+ ) -> TwoWayMatchResult:
258
+ """
259
+ Layers GR checks (§5.1–5.6) on top of an already-completed 2-way match result.
260
+ Always sets match_type='three_way'.
261
+
262
+ Check order (stop-on-fire for checks 1-3):
263
+ 1. no_accepted_gr — stop if no accepted/partial_accept GRs exist
264
+ 2. invoice_before_gr — stop if invoice pre-dates earliest accepted GR; zone→critical
265
+ 3. gr_inspection_rejected — stop if any routed GR row has status='rejected'
266
+ 4. qty_over_received — per matched PO line (Tier 1-3) or GL group (Tier 4)
267
+
268
+ 2-way line counts (lines_total / lines_matched / lines_in_exception) are never modified;
269
+ only match_zone, individual line_zone fields, match_exceptions, and overall_status change.
270
+ """
271
+ cfg = _cfg_from_dict(matching_configs or {})
272
+
273
+ result = two_way_result
274
+ result.match_type = "three_way"
275
+
276
+ inv_by_id: dict[int, InvoiceLine] = {il.id: il for il in inv_lines}
277
+ po_by_id: dict[int, POLine] = {pl.id: pl for pl in po_lines}
278
+
279
+ # Collect matched pairs from 2-way, split by tier
280
+ tier1_3: list[tuple[int, int]] = []
281
+ tier4: list[tuple[int, int]] = []
282
+ for mlr in result.match_line_results:
283
+ if mlr.line_status != "matched":
284
+ continue
285
+ (tier4 if mlr.match_tier == 4 else tier1_3).append(
286
+ (mlr.invoice_line_id, mlr.po_line_id)
287
+ )
288
+
289
+ all_po_ids: set[int] = {po_id for _, po_id in tier1_3 + tier4}
290
+ if not all_po_ids:
291
+ return result # nothing matched in 2-way; nothing to GR-check
292
+
293
+ # Route GR rows to matched PO lines.
294
+ # Exclude: soft-deleted rows, and 'returned' rows (those belong to credit-note matching §5.5).
295
+ routed = [
296
+ g for g in gr_lines
297
+ if g.po_line_id in all_po_ids
298
+ and g.deleted_at is None
299
+ and g.inspection_status != "returned"
300
+ ]
301
+
302
+ # ── Check 1: no accepted GR ────────────────────────────────────────────────
303
+ # Timing-fraud source set: accepted / partial_accept only (§5.1)
304
+ accepted = [g for g in routed if g.inspection_status in ("accepted", "partial_accept")]
305
+ if not accepted:
306
+ result.match_exceptions.append(MatchExceptionRecord(
307
+ exception_type="no_accepted_gr",
308
+ exception_detail={"reason": "no_accepted_gr",
309
+ "matched_po_line_ids": sorted(all_po_ids)},
310
+ ))
311
+ result.overall_status = "exception"
312
+ return result
313
+
314
+ # ── Check 2: timing fraud ─────────────────────────────────────────────────
315
+ # Skip if invoice_date IS NULL (Cat 2 incomplete_fields already owns missing date).
316
+ if invoice.invoice_date is not None:
317
+ earliest_gr_date = min(g.gr_date for g in accepted) # DATE-level comparison
318
+ if invoice.invoice_date < earliest_gr_date:
319
+ result.match_exceptions.append(MatchExceptionRecord(
320
+ exception_type="invoice_before_gr",
321
+ exception_detail={
322
+ "reason": "invoice_before_gr",
323
+ "invoice_date": str(invoice.invoice_date),
324
+ "earliest_gr_date": str(earliest_gr_date),
325
+ },
326
+ ))
327
+ result.risk_flags.append("invoice_before_gr")
328
+ result.match_zone = "critical" # §8.3 — integrity signal overrides normal zones
329
+ result.overall_status = "exception"
330
+ return result
331
+
332
+ # ── Check 3: inspection rejection ─────────────────────────────────────────
333
+ # Source set: ALL routed non-returned GR rows (§5.1) — rejected status fires regardless of
334
+ # whether the PO line also has accepted GRs.
335
+ rejected = [g for g in routed if g.inspection_status == "rejected"]
336
+ if rejected:
337
+ result.match_exceptions.append(MatchExceptionRecord(
338
+ exception_type="gr_inspection_rejected",
339
+ exception_detail={
340
+ "reason": "gr_inspection_rejected",
341
+ "rejected_gr_ids": sorted(g.id for g in rejected),
342
+ },
343
+ ))
344
+ result.overall_status = "exception"
345
+ return result
346
+
347
+ # ── Check 4: quantity over-received ────────────────────────────���──────────
348
+ # Build accepted-GR index per PO line, FIFO-ordered (gr_date ASC, id ASC).
349
+ # This ordering is analytical only — nothing is physically decremented (§5.4).
350
+ accepted_by_po: dict[int, list[GRLineItem]] = defaultdict(list)
351
+ for g in accepted:
352
+ if g.po_line_id is not None:
353
+ accepted_by_po[g.po_line_id].append(g)
354
+ for rows in accepted_by_po.values():
355
+ rows.sort(key=lambda g: (g.gr_date, g.id))
356
+
357
+ # Tier 1-3: one check per direct (inv_line, po_line) match
358
+ for inv_id, po_id in tier1_3:
359
+ il = inv_by_id.get(inv_id)
360
+ pl = po_by_id.get(po_id)
361
+ if il is None or pl is None:
362
+ continue
363
+ if pl.item_type in (None, "services"):
364
+ continue # services and NULL-typed lines have no GR obligation
365
+ try:
366
+ _qty_check_single(
367
+ inv_line=il, po_line=pl,
368
+ accepted_by_po=accepted_by_po,
369
+ result=result, cfg=cfg,
370
+ )
371
+ except RuntimeError as exc:
372
+ result.match_exceptions.append(MatchExceptionRecord(
373
+ exception_type="data_integrity_violation",
374
+ line_id=inv_id,
375
+ po_line_id=po_id,
376
+ exception_detail={"reason": "idp17_quantity_accepted_null", "detail": str(exc)},
377
+ ))
378
+ _push_zone(result, inv_id, "red")
379
+ result.overall_status = "exception"
380
+
381
+ # Tier 4: aggregate check per GL group (§5.6)
382
+ if tier4:
383
+ # Reconstruct GL groups from inv_line.gl_account
384
+ gl_groups: dict[str, dict[str, set]] = defaultdict(
385
+ lambda: {"inv": set(), "po": set()}
386
+ )
387
+ for inv_id, po_id in tier4:
388
+ il = inv_by_id.get(inv_id)
389
+ if il is None:
390
+ continue
391
+ key = il.gl_account or "__null__"
392
+ gl_groups[key]["inv"].add(inv_id)
393
+ gl_groups[key]["po"].add(po_id)
394
+
395
+ for gl_key, ids in gl_groups.items():
396
+ try:
397
+ _qty_check_tier4_group(
398
+ gl_account = None if gl_key == "__null__" else gl_key,
399
+ group_inv_lines= [inv_by_id[i] for i in ids["inv"] if i in inv_by_id],
400
+ group_po_lines = [po_by_id[i] for i in ids["po"] if i in po_by_id],
401
+ accepted_by_po = accepted_by_po,
402
+ accepted_gr = accepted,
403
+ result=result, cfg=cfg,
404
+ )
405
+ except RuntimeError as exc:
406
+ result.match_exceptions.append(MatchExceptionRecord(
407
+ exception_type="data_integrity_violation",
408
+ exception_detail={
409
+ "reason": "idp17_quantity_accepted_null",
410
+ "gl_account": None if gl_key == "__null__" else gl_key,
411
+ "detail": str(exc),
412
+ },
413
+ ))
414
+ result.overall_status = "exception"
415
+
416
+ return result
src/Matching Functions/two_way_match.py ADDED
@@ -0,0 +1,1025 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import date
5
+ from decimal import Decimal, ROUND_HALF_EVEN, getcontext
6
+ from typing import Any, Callable, Optional
7
+ import re
8
+ import unicodedata
9
+
10
+ from rapidfuzz import fuzz
11
+
12
+
13
+ # ============================================================
14
+ # Decimal / arithmetic hygiene
15
+ # ============================================================
16
+ getcontext().prec = 28
17
+ getcontext().rounding = ROUND_HALF_EVEN
18
+
19
+ D0 = Decimal("0")
20
+ D100 = Decimal("100")
21
+
22
+
23
+ # ============================================================
24
+ # Data models
25
+ # ============================================================
26
+ @dataclass(frozen=True)
27
+ class InvoiceLine:
28
+ id: int
29
+ description: Optional[str] = None
30
+ description_canonical: Optional[str] = None
31
+ sku: Optional[str] = None
32
+ sku_canonical: Optional[str] = None
33
+ gl_account: Optional[str] = None
34
+ quantity: Optional[Decimal] = None
35
+ unit_price: Optional[Decimal] = None
36
+ amount: Optional[Decimal] = None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class POLine:
41
+ id: int
42
+ description: Optional[str] = None
43
+ description_canonical: Optional[str] = None
44
+ sku: Optional[str] = None
45
+ sku_canonical: Optional[str] = None
46
+ gl_account: Optional[str] = None
47
+ quantity: Optional[Decimal] = None
48
+ invoiced_qty: Optional[Decimal] = None
49
+ unit_price: Optional[Decimal] = None
50
+ item_type: Optional[str] = None # 'goods' | 'services' | None
51
+ status: str = "open" # 'open' | 'fully_invoiced' | 'closed' | 'cancelled'
52
+ deleted_at: Any = None
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class POHeader:
57
+ id: int
58
+ vendor_id: Optional[int] = None
59
+ currency: Optional[str] = None
60
+ status: str = "open"
61
+ deleted_at: Any = None
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class InvoiceHeader:
66
+ id: int
67
+ status: str
68
+ vendor_id: Optional[int] = None
69
+ currency: Optional[str] = None
70
+ total_amount: Optional[Decimal] = None
71
+ advisory_flags: tuple[str, ...] = ()
72
+ doc_type: str = "invoice"
73
+ tenant_id: Optional[int] = None
74
+ invoice_date: Optional[date] = None
75
+
76
+
77
+ @dataclass
78
+ class MatchExceptionRecord:
79
+ exception_type: str
80
+ line_id: Optional[int] = None
81
+ po_line_id: Optional[int] = None
82
+ exception_detail: dict[str, Any] = field(default_factory=dict)
83
+
84
+
85
+ @dataclass
86
+ class MatchLineResult:
87
+ invoice_line_id: int
88
+ po_line_id: Optional[int]
89
+ match_tier: int
90
+ allocated_inv_amount: Optional[Decimal] = None
91
+ allocated_po_amount: Optional[Decimal] = None
92
+ price_var_pct: Optional[Decimal] = None
93
+ qty_var_pct: Optional[Decimal] = None
94
+ line_zone: str = "green" # green | amber | red
95
+ line_status: str = "matched" # matched | no_match | exception
96
+ match_type: str = "two_way"
97
+
98
+
99
+ @dataclass
100
+ class TwoWayMatchResult:
101
+ match_type: str = "two_way"
102
+ overall_status: str = "exception" # matched | partial_match | exception
103
+ match_zone: str = "green" # green | amber | red
104
+ documents_status: str = "pending_approval"
105
+
106
+ lines_total: int = 0
107
+ lines_matched: int = 0
108
+ lines_in_exception: int = 0
109
+
110
+ risk_flags: list[str] = field(default_factory=list)
111
+ advisory_flags: list[str] = field(default_factory=list)
112
+ skipped_steps: list[dict] = field(default_factory=list)
113
+ header_exceptions: list[MatchExceptionRecord] = field(default_factory=list)
114
+ match_exceptions: list[MatchExceptionRecord] = field(default_factory=list)
115
+ match_line_results: list[MatchLineResult] = field(default_factory=list)
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class TwoWayConfig:
120
+ tier3_price_variance_threshold: Decimal = Decimal("0.10")
121
+
122
+ price_goods_green: Decimal = Decimal("5")
123
+ price_goods_amber: Decimal = Decimal("10")
124
+ price_services_green: Decimal = Decimal("10")
125
+ price_services_amber: Decimal = Decimal("15")
126
+
127
+ qty_goods_green: Decimal = Decimal("2")
128
+ qty_goods_amber: Decimal = Decimal("4")
129
+ qty_services_green: Decimal = Decimal("3")
130
+ qty_services_amber: Decimal = Decimal("6")
131
+
132
+ tier4_gl_group_max_lines: int = 100
133
+
134
+ fuzzy_high_threshold: float = 90.0
135
+ fuzzy_medium_threshold: float = 80.0
136
+ fuzzy_score_cutoff: float = 80.0
137
+
138
+ precision: int = 28
139
+
140
+
141
+ def _cfg_from_dict(d: dict) -> TwoWayConfig:
142
+ """Build TwoWayConfig from matching_configs dict. Falls back to dataclass defaults for missing keys."""
143
+ def _d(key: str, default: str) -> Decimal:
144
+ return Decimal(str(d.get(key, default)))
145
+
146
+ def _i(key: str, default: int) -> int:
147
+ return int(d.get(key, default))
148
+
149
+ def _f(key: str, default: float) -> float:
150
+ return float(d.get(key, default))
151
+
152
+ return TwoWayConfig(
153
+ tier3_price_variance_threshold=_d("tier3_price_variance_threshold", "0.10"),
154
+ price_goods_green=_d("price_goods_green", "5"),
155
+ price_goods_amber=_d("price_goods_amber", "10"),
156
+ price_services_green=_d("price_services_green", "10"),
157
+ price_services_amber=_d("price_services_amber", "15"),
158
+ qty_goods_green=_d("qty_goods_green", "2"),
159
+ qty_goods_amber=_d("qty_goods_amber", "4"),
160
+ qty_services_green=_d("qty_services_green", "3"),
161
+ qty_services_amber=_d("qty_services_amber", "6"),
162
+ tier4_gl_group_max_lines=_i("tier4_gl_group_max_lines", 100),
163
+ fuzzy_high_threshold=_f("fuzzy_high_threshold", 90.0),
164
+ fuzzy_medium_threshold=_f("fuzzy_medium_threshold", 80.0),
165
+ fuzzy_score_cutoff=_f("fuzzy_score_cutoff", 80.0),
166
+ precision=_i("precision", 28),
167
+ )
168
+
169
+
170
+ # ============================================================
171
+ # Helper functions
172
+ # ============================================================
173
+ def to_decimal(value: Any) -> Optional[Decimal]:
174
+ if value is None or value == "":
175
+ return None
176
+ if isinstance(value, Decimal):
177
+ return value
178
+ return Decimal(str(value))
179
+
180
+
181
+ def canonicalise_identifier(value: Optional[str]) -> Optional[str]:
182
+ """
183
+ NFKC + uppercase + strip non-alphanumeric.
184
+ """
185
+ if value is None:
186
+ return None
187
+ value = unicodedata.normalize("NFKC", str(value)).upper()
188
+ value = re.sub(r"[^A-Z0-9]+", "", value)
189
+ return value or None
190
+
191
+
192
+ def norm_currency(value: Optional[str]) -> Optional[str]:
193
+ return value.upper().strip() if value else None
194
+
195
+
196
+ def price_variance_pct(inv_price: Optional[Decimal], po_price: Optional[Decimal]) -> Optional[Decimal]:
197
+ """
198
+ abs(inv - po) / po * 100
199
+ Skip if invoice price is NULL or po price is NULL or po price == 0.
200
+ """
201
+ if inv_price is None or po_price is None or po_price == 0:
202
+ return None
203
+ return (abs(inv_price - po_price) / po_price * D100).quantize(Decimal("0.0001"))
204
+
205
+
206
+ def qty_over_pct(inv_qty: Optional[Decimal], available_qty: Optional[Decimal]) -> Optional[Decimal]:
207
+ """
208
+ max(0, (inv_qty - available_qty) / available_qty * 100)
209
+ Skip if inv_qty is NULL or available_qty is NULL/zero.
210
+ """
211
+ if inv_qty is None or available_qty is None or available_qty == 0:
212
+ return None
213
+ return (max(D0, (inv_qty - available_qty) / available_qty * D100)).quantize(Decimal("0.0001"))
214
+
215
+
216
+ def available_qty(po_line: POLine) -> Decimal:
217
+ q = to_decimal(po_line.quantity) or D0
218
+ iq = to_decimal(po_line.invoiced_qty) or D0
219
+ return q - iq
220
+
221
+
222
+ def remaining_po_value(po_lines: list[POLine]) -> Decimal:
223
+ total = D0
224
+ for line in po_lines:
225
+ if line.deleted_at is not None:
226
+ continue
227
+ if line.status != "open":
228
+ continue
229
+ q = to_decimal(line.quantity) or D0
230
+ iq = to_decimal(line.invoiced_qty) or D0
231
+ up = to_decimal(line.unit_price) or D0
232
+ total += (q - iq) * up
233
+ return total
234
+
235
+
236
+ def same_vendor_merge_chain(
237
+ invoice_vendor_id: Optional[int],
238
+ po_vendor_id: Optional[int],
239
+ merge_chain_fn: Optional[Callable[[Optional[int], Optional[int]], bool]] = None,
240
+ ) -> bool:
241
+ if invoice_vendor_id is None or po_vendor_id is None:
242
+ return False
243
+ if merge_chain_fn is not None:
244
+ return bool(merge_chain_fn(invoice_vendor_id, po_vendor_id))
245
+ return invoice_vendor_id == po_vendor_id
246
+
247
+
248
+ def zone_from_pct(
249
+ price_pct: Optional[Decimal],
250
+ qty_pct: Optional[Decimal],
251
+ item_type: Optional[str],
252
+ cfg: TwoWayConfig,
253
+ ) -> str:
254
+ """
255
+ Worst-wins among price and qty, ignoring NULL dimensions.
256
+ green -> amber -> red
257
+ """
258
+ item_type = (item_type or "services").lower()
259
+
260
+ if item_type == "goods":
261
+ price_green, price_amber = cfg.price_goods_green, cfg.price_goods_amber
262
+ qty_green, qty_amber = cfg.qty_goods_green, cfg.qty_goods_amber
263
+ else:
264
+ price_green, price_amber = cfg.price_services_green, cfg.price_services_amber
265
+ qty_green, qty_amber = cfg.qty_services_green, cfg.qty_services_amber
266
+
267
+ def single_zone(pct: Optional[Decimal], green: Decimal, amber: Decimal) -> Optional[str]:
268
+ if pct is None:
269
+ return None
270
+ if pct <= green:
271
+ return "green"
272
+ if pct <= amber:
273
+ return "amber"
274
+ return "red"
275
+
276
+ zones = [
277
+ single_zone(price_pct, price_green, price_amber),
278
+ single_zone(qty_pct, qty_green, qty_amber),
279
+ ]
280
+ zones = [z for z in zones if z is not None]
281
+
282
+ if not zones:
283
+ return "green"
284
+ if "red" in zones:
285
+ return "red"
286
+ if "amber" in zones:
287
+ return "amber"
288
+ return "green"
289
+
290
+
291
+ def worse_zone(a: str, b: str) -> str:
292
+ rank = {"green": 0, "amber": 1, "red": 2}
293
+ return a if rank[a] >= rank[b] else b
294
+
295
+
296
+ def _price_bands(item_type: Optional[str], cfg: TwoWayConfig) -> tuple[Decimal, Decimal]:
297
+ item_type = (item_type or "services").lower()
298
+ if item_type == "goods":
299
+ return cfg.price_goods_green, cfg.price_goods_amber
300
+ return cfg.price_services_green, cfg.price_services_amber
301
+
302
+
303
+ def _qty_bands(item_type: Optional[str], cfg: TwoWayConfig) -> tuple[Decimal, Decimal]:
304
+ item_type = (item_type or "services").lower()
305
+ if item_type == "goods":
306
+ return cfg.qty_goods_green, cfg.qty_goods_amber
307
+ return cfg.qty_services_green, cfg.qty_services_amber
308
+
309
+
310
+ def rapidfuzz_best_description_match(
311
+ query: str,
312
+ candidates: list[dict[str, Any]],
313
+ *,
314
+ high_threshold: float = 90.0,
315
+ medium_threshold: float = 80.0,
316
+ score_cutoff: float = 80.0,
317
+ ) -> dict[str, Any]:
318
+ """
319
+ Returns:
320
+ {
321
+ "decision": "matched" | "suggestion" | "unmatched",
322
+ "score_tier": "high" | "medium" | "low" | None,
323
+ "score": float,
324
+ "record_id": int | None
325
+ }
326
+ """
327
+ if not query or not candidates:
328
+ return {
329
+ "decision": "unmatched",
330
+ "score_tier": None,
331
+ "score": 0.0,
332
+ "record_id": None,
333
+ }
334
+
335
+ best_record_id = None
336
+ best_score = 0.0
337
+
338
+ for cand in candidates:
339
+ record_id = cand.get("record_id")
340
+ names = cand.get("names") or []
341
+ for cand_name in names:
342
+ if not cand_name:
343
+ continue
344
+ score = float(
345
+ fuzz.WRatio(
346
+ query,
347
+ cand_name,
348
+ score_cutoff=score_cutoff,
349
+ )
350
+ )
351
+ if score > best_score:
352
+ best_score = score
353
+ best_record_id = record_id
354
+
355
+ if best_record_id is None or best_score < score_cutoff:
356
+ return {
357
+ "decision": "unmatched",
358
+ "score_tier": None,
359
+ "score": 0.0,
360
+ "record_id": None,
361
+ }
362
+
363
+ if best_score >= high_threshold:
364
+ score_tier = "high"
365
+ elif best_score >= medium_threshold:
366
+ score_tier = "medium"
367
+ else:
368
+ score_tier = "low"
369
+
370
+ decision = "matched" if score_tier in {"high", "medium"} else "suggestion"
371
+
372
+ return {
373
+ "decision": decision,
374
+ "score_tier": score_tier,
375
+ "score": round(best_score, 2),
376
+ "record_id": best_record_id,
377
+ }
378
+
379
+
380
+ # ============================================================
381
+ # Main 2-way matching function
382
+ # ============================================================
383
+ def run_two_way_match_core(
384
+ invoice: InvoiceHeader,
385
+ inv_lines: list[InvoiceLine],
386
+ po: POHeader,
387
+ po_lines: list[POLine],
388
+ *,
389
+ matching_configs: Optional[dict] = None,
390
+ merge_chain_fn: Optional[Callable[[Optional[int], Optional[int]], bool]] = None,
391
+ lock_candidate_po_lines_fn: Optional[Callable[[list[int]], None]] = None,
392
+ ) -> TwoWayMatchResult:
393
+ """
394
+ Core 2-way matching engine.
395
+
396
+ Implements:
397
+ - entry precondition on invoice.status
398
+ - header checks: currency, vendor merge-chain aware, PO value ceiling advisory
399
+ - Tier 1: SKU exact canonical match
400
+ - Tier 2: RapidFuzz description match
401
+ - Tier 3: GL exact + price tolerance
402
+ - Tier 4: aggregate GL matching over residue
403
+ - variance computation and zones
404
+ - exceptions and advisory flags
405
+ - analytical pool consumption only (no DB writes)
406
+ """
407
+
408
+ cfg = _cfg_from_dict(matching_configs or {})
409
+ getcontext().prec = cfg.precision
410
+
411
+ result = TwoWayMatchResult()
412
+ result.lines_total = len(inv_lines)
413
+ result.advisory_flags = list(invoice.advisory_flags)
414
+
415
+ invoice_currency = norm_currency(invoice.currency)
416
+ po_currency = norm_currency(po.currency)
417
+
418
+ # ---------------------------------------------------------
419
+ # Entry preconditions
420
+ # ---------------------------------------------------------
421
+ if invoice.status not in {"validated", "matching"}:
422
+ raise ValueError(
423
+ f"Precondition violation: invoice.status={invoice.status!r} "
424
+ f"must be in ('validated', 'matching')."
425
+ )
426
+
427
+ # ---------------------------------------------------------
428
+ # Header Check 1: Currency
429
+ # ---------------------------------------------------------
430
+ currency_ok = True
431
+ if invoice_currency is None:
432
+ currency_ok = False
433
+ result.skipped_steps.append({"step": "currency_check", "skip_reason": "ef_currency_null"})
434
+ elif po_currency is None or invoice_currency != po_currency:
435
+ currency_ok = False
436
+ result.header_exceptions.append(
437
+ MatchExceptionRecord(
438
+ exception_type="currency_mismatch",
439
+ exception_detail={
440
+ "invoice_currency": invoice_currency,
441
+ "po_currency": po_currency,
442
+ },
443
+ )
444
+ )
445
+
446
+ # ---------------------------------------------------------
447
+ # Header Check 2: Vendor (merge-chain aware)
448
+ # ---------------------------------------------------------
449
+ vendor_ok = same_vendor_merge_chain(invoice.vendor_id, po.vendor_id, merge_chain_fn)
450
+ if not vendor_ok:
451
+ result.header_exceptions.append(
452
+ MatchExceptionRecord(
453
+ exception_type="vendor_mismatch",
454
+ exception_detail={
455
+ "invoice_vendor_id": invoice.vendor_id,
456
+ "po_vendor_id": po.vendor_id,
457
+ "merge_chain_aware": bool(merge_chain_fn is not None),
458
+ },
459
+ )
460
+ )
461
+
462
+ # ---------------------------------------------------------
463
+ # Header Check 3: PO value ceiling (advisory only)
464
+ # ---------------------------------------------------------
465
+ if not currency_ok:
466
+ result.skipped_steps.append({"step": "po_value_ceiling", "skip_reason": "currency_mismatch_or_null"})
467
+ elif invoice.total_amount is not None:
468
+ rem_value = remaining_po_value(po_lines)
469
+ if invoice.total_amount > rem_value:
470
+ result.advisory_flags.append("exceeds_remaining_po_value_at_match")
471
+
472
+ # ---------------------------------------------------------
473
+ # Build matching pool
474
+ # ---------------------------------------------------------
475
+ pool: list[POLine] = [
476
+ pl for pl in po_lines
477
+ if pl.deleted_at is None and pl.status in {"open", "fully_invoiced"}
478
+ ]
479
+
480
+ # Optional lock hook for caller-side DB advisory locks
481
+ if lock_candidate_po_lines_fn is not None:
482
+ lock_candidate_po_lines_fn([pl.id for pl in sorted(pool, key=lambda x: x.id)])
483
+
484
+ consumed_po_line_ids: set[int] = set()
485
+
486
+ def pool_remaining() -> list[POLine]:
487
+ return [pl for pl in pool if pl.id not in consumed_po_line_ids]
488
+
489
+ def consume_po_line(po_line_id: int) -> None:
490
+ consumed_po_line_ids.add(po_line_id)
491
+
492
+ # ---------------------------------------------------------
493
+ # Tier 1-3 per-line waterfall
494
+ # ---------------------------------------------------------
495
+ residue_lines: list[InvoiceLine] = []
496
+ line_exception_map: dict[int, list[MatchExceptionRecord]] = {}
497
+ item_type_unresolved_flagged = False
498
+
499
+ def add_line_exception(line_id: int, exc: MatchExceptionRecord) -> None:
500
+ line_exception_map.setdefault(line_id, []).append(exc)
501
+ result.match_exceptions.append(exc)
502
+
503
+ for inv_line in inv_lines:
504
+ inv_sku_canon = inv_line.sku_canonical or canonicalise_identifier(inv_line.sku)
505
+ inv_desc_canon = inv_line.description_canonical or canonicalise_identifier(inv_line.description)
506
+
507
+ matched_this_line = False
508
+
509
+ # -------------------------
510
+ # Tier 1: SKU exact match
511
+ # -------------------------
512
+ if inv_sku_canon:
513
+ candidates = [
514
+ pl for pl in pool_remaining()
515
+ if (pl.sku_canonical or canonicalise_identifier(pl.sku)) == inv_sku_canon
516
+ ]
517
+ candidates.sort(key=lambda x: x.id)
518
+
519
+ if candidates:
520
+ chosen = candidates[0]
521
+ if chosen.item_type is None and not item_type_unresolved_flagged:
522
+ result.advisory_flags.append("item_type_unresolved")
523
+ item_type_unresolved_flagged = True
524
+ matched, line_result = _finalize_tier1_3_line_match(
525
+ inv_line=inv_line,
526
+ po_line=chosen,
527
+ tier=1,
528
+ cfg=cfg,
529
+ )
530
+ if matched:
531
+ result.match_line_results.append(line_result)
532
+ consume_po_line(chosen.id)
533
+ matched_this_line = True
534
+ else:
535
+ add_line_exception(
536
+ inv_line.id,
537
+ MatchExceptionRecord(
538
+ exception_type="po_ceiling_exhausted",
539
+ line_id=inv_line.id,
540
+ po_line_id=chosen.id,
541
+ exception_detail={
542
+ "reason": "available_qty_zero_or_negative",
543
+ "po_line_id": chosen.id,
544
+ },
545
+ ),
546
+ )
547
+ matched_this_line = True
548
+
549
+ if matched_this_line:
550
+ continue
551
+
552
+ # -------------------------
553
+ # Tier 2: RapidFuzz description match
554
+ # -------------------------
555
+ pool_now = pool_remaining()
556
+ if inv_desc_canon and pool_now:
557
+ tier2_candidates = [
558
+ {
559
+ "record_id": pl.id,
560
+ "names": [
561
+ pl.description_canonical
562
+ or canonicalise_identifier(pl.description)
563
+ or ""
564
+ ],
565
+ }
566
+ for pl in pool_now
567
+ ]
568
+ tier2_candidates = [c for c in tier2_candidates if c["names"][0]]
569
+
570
+ if tier2_candidates:
571
+ rf_result = rapidfuzz_best_description_match(
572
+ query=inv_desc_canon,
573
+ candidates=tier2_candidates,
574
+ high_threshold=cfg.fuzzy_high_threshold,
575
+ medium_threshold=cfg.fuzzy_medium_threshold,
576
+ score_cutoff=cfg.fuzzy_score_cutoff,
577
+ )
578
+
579
+ if rf_result["decision"] == "matched" and rf_result["record_id"] is not None:
580
+ chosen = next((pl for pl in pool_now if pl.id == rf_result["record_id"]), None)
581
+ if chosen is not None:
582
+ if chosen.item_type is None and not item_type_unresolved_flagged:
583
+ result.advisory_flags.append("item_type_unresolved")
584
+ item_type_unresolved_flagged = True
585
+ matched, line_result = _finalize_tier1_3_line_match(
586
+ inv_line=inv_line,
587
+ po_line=chosen,
588
+ tier=2,
589
+ cfg=cfg,
590
+ )
591
+ if matched:
592
+ result.match_line_results.append(line_result)
593
+ consume_po_line(chosen.id)
594
+ matched_this_line = True
595
+ else:
596
+ add_line_exception(
597
+ inv_line.id,
598
+ MatchExceptionRecord(
599
+ exception_type="po_ceiling_exhausted",
600
+ line_id=inv_line.id,
601
+ po_line_id=chosen.id,
602
+ exception_detail={
603
+ "reason": "available_qty_zero_or_negative",
604
+ "po_line_id": chosen.id,
605
+ },
606
+ ),
607
+ )
608
+ matched_this_line = True
609
+
610
+ if matched_this_line:
611
+ continue
612
+
613
+ # -------------------------
614
+ # Tier 3: GL exact + price tolerance
615
+ # -------------------------
616
+ tier3_candidates: list[POLine] = []
617
+ if inv_line.gl_account is not None:
618
+ for pl in pool_remaining():
619
+ if pl.gl_account is None:
620
+ continue
621
+ if inv_line.gl_account != pl.gl_account:
622
+ continue
623
+ if pl.unit_price is None or pl.unit_price == 0:
624
+ continue
625
+ tier3_candidates.append(pl)
626
+
627
+ tier3_candidates.sort(key=lambda x: x.id)
628
+
629
+ for chosen in tier3_candidates:
630
+ p_var = price_variance_pct(to_decimal(inv_line.unit_price), to_decimal(chosen.unit_price))
631
+ if p_var is None:
632
+ continue
633
+
634
+ if p_var <= (cfg.tier3_price_variance_threshold * D100):
635
+ if chosen.item_type is None and not item_type_unresolved_flagged:
636
+ result.advisory_flags.append("item_type_unresolved")
637
+ item_type_unresolved_flagged = True
638
+ matched, line_result = _finalize_tier1_3_line_match(
639
+ inv_line=inv_line,
640
+ po_line=chosen,
641
+ tier=3,
642
+ cfg=cfg,
643
+ )
644
+ if matched:
645
+ result.match_line_results.append(line_result)
646
+ consume_po_line(chosen.id)
647
+ matched_this_line = True
648
+ else:
649
+ add_line_exception(
650
+ inv_line.id,
651
+ MatchExceptionRecord(
652
+ exception_type="po_ceiling_exhausted",
653
+ line_id=inv_line.id,
654
+ po_line_id=chosen.id,
655
+ exception_detail={
656
+ "reason": "available_qty_zero_or_negative",
657
+ "po_line_id": chosen.id,
658
+ },
659
+ ),
660
+ )
661
+ matched_this_line = True
662
+ break
663
+
664
+ if matched_this_line:
665
+ continue
666
+
667
+ # No Tier 1-3 hit
668
+ residue_lines.append(inv_line)
669
+
670
+ # ---------------------------------------------------------
671
+ # Tier 4: Aggregate GL match on residue
672
+ # ---------------------------------------------------------
673
+ residue_lines_by_gl: dict[str, list[InvoiceLine]] = {}
674
+ for line in residue_lines:
675
+ if line.gl_account is None:
676
+ add_line_exception(
677
+ line.id,
678
+ MatchExceptionRecord(
679
+ exception_type="no_match",
680
+ line_id=line.id,
681
+ exception_detail={
682
+ "reason": "gl_account_null_tier4_skip",
683
+ },
684
+ ),
685
+ )
686
+ continue
687
+ residue_lines_by_gl.setdefault(line.gl_account, []).append(line)
688
+
689
+ residue_po_lines = pool_remaining()
690
+ po_lines_by_gl: dict[str, list[POLine]] = {}
691
+ for pl in residue_po_lines:
692
+ if pl.gl_account is None:
693
+ continue
694
+ po_lines_by_gl.setdefault(pl.gl_account, []).append(pl)
695
+
696
+ for gl, group_inv_lines in residue_lines_by_gl.items():
697
+ group_po_lines = po_lines_by_gl.get(gl, [])
698
+ if not group_po_lines:
699
+ for il in group_inv_lines:
700
+ add_line_exception(
701
+ il.id,
702
+ MatchExceptionRecord(
703
+ exception_type="no_match",
704
+ line_id=il.id,
705
+ exception_detail={
706
+ "reason": "no_po_lines_in_same_gl_group",
707
+ "gl_account": gl,
708
+ },
709
+ ),
710
+ )
711
+ continue
712
+
713
+ group_inv_lines.sort(key=lambda x: x.id)
714
+ group_po_lines.sort(key=lambda x: x.id)
715
+
716
+ # Tier 4 group cap
717
+ if len(group_inv_lines) > cfg.tier4_gl_group_max_lines:
718
+ for il in group_inv_lines:
719
+ add_line_exception(
720
+ il.id,
721
+ MatchExceptionRecord(
722
+ exception_type="no_match",
723
+ line_id=il.id,
724
+ exception_detail={
725
+ "reason": "tier4_group_cap_exceeded",
726
+ "group_gl_account": gl,
727
+ "group_line_count": len(group_inv_lines),
728
+ "cap": cfg.tier4_gl_group_max_lines,
729
+ },
730
+ ),
731
+ )
732
+ continue
733
+
734
+ # Invoice amount sum
735
+ if all(il.amount is None for il in group_inv_lines):
736
+ for il in group_inv_lines:
737
+ add_line_exception(
738
+ il.id,
739
+ MatchExceptionRecord(
740
+ exception_type="no_match",
741
+ line_id=il.id,
742
+ exception_detail={
743
+ "reason": "all_invoice_amounts_null",
744
+ "group_gl_account": gl,
745
+ },
746
+ ),
747
+ )
748
+ continue
749
+
750
+ invoice_sum = sum((il.amount if il.amount is not None else D0 for il in group_inv_lines), D0)
751
+
752
+ # PO group sum — SDD §4.3: status='open' only.
753
+ # any_goods checks all lines (fully_invoiced included) because goods-first is a
754
+ # type-classification rule, not an availability rule.
755
+ open_group_po_lines = [pl for pl in group_po_lines if pl.status == "open"]
756
+ po_group_sum = D0
757
+ any_goods = False
758
+ for pl in group_po_lines:
759
+ if (pl.item_type or "").lower() == "goods":
760
+ any_goods = True
761
+ for pl in open_group_po_lines:
762
+ rem_qty = available_qty(pl)
763
+ up = to_decimal(pl.unit_price)
764
+ if up is None:
765
+ continue
766
+ po_group_sum += rem_qty * up
767
+
768
+ if any(pl.item_type is None for pl in group_po_lines) and not item_type_unresolved_flagged:
769
+ result.advisory_flags.append("item_type_unresolved")
770
+ item_type_unresolved_flagged = True
771
+
772
+ if po_group_sum == 0:
773
+ all_zero_price = (
774
+ bool(open_group_po_lines)
775
+ and all((to_decimal(pl.unit_price) or D0) == D0 for pl in open_group_po_lines)
776
+ )
777
+ exc_type = "zero_price_po_line_at_match" if all_zero_price else "no_match"
778
+ for il in group_inv_lines:
779
+ add_line_exception(
780
+ il.id,
781
+ MatchExceptionRecord(
782
+ exception_type=exc_type,
783
+ line_id=il.id,
784
+ exception_detail={
785
+ "reason": "po_group_sum_zero",
786
+ "group_gl_account": gl,
787
+ "po_line_ids": [pl.id for pl in group_po_lines],
788
+ },
789
+ ),
790
+ )
791
+ continue
792
+
793
+ group_variance_pct = (abs(invoice_sum - po_group_sum) / po_group_sum * D100).quantize(Decimal("0.0001"))
794
+ group_item_type = "goods" if any_goods else "services"
795
+
796
+ # Quantity sanity (before accepting Tier 4)
797
+ inv_qty_sum = sum((to_decimal(il.quantity) or D0 for il in group_inv_lines), D0)
798
+ po_available_qty_sum = sum((available_qty(pl) for pl in group_po_lines), D0)
799
+
800
+ if inv_qty_sum > 0 and po_available_qty_sum > 0:
801
+ qty_var = qty_over_pct(inv_qty_sum, po_available_qty_sum)
802
+ if qty_var is not None:
803
+ _, qty_amber = _qty_bands(group_item_type, cfg)
804
+ if qty_var > qty_amber:
805
+ for il in group_inv_lines:
806
+ add_line_exception(
807
+ il.id,
808
+ MatchExceptionRecord(
809
+ exception_type="no_match",
810
+ line_id=il.id,
811
+ exception_detail={
812
+ "reason": "tier4_qty_sanity_failed",
813
+ "group_gl_account": gl,
814
+ "inv_qty_sum": str(inv_qty_sum),
815
+ "po_available_qty_sum": str(po_available_qty_sum),
816
+ "qty_over_pct": str(qty_var),
817
+ },
818
+ ),
819
+ )
820
+ continue
821
+
822
+ # Price tolerance for Tier 4
823
+ price_green, price_amber = _price_bands(group_item_type, cfg)
824
+ if group_variance_pct <= price_amber:
825
+ # Match the whole group and persist deterministic proportional allocations.
826
+ for il in group_inv_lines:
827
+ il_amount = il.amount if il.amount is not None else D0
828
+
829
+ raw_allocations: list[tuple[POLine, Decimal]] = []
830
+ for pl in open_group_po_lines:
831
+ rem_qty = available_qty(pl)
832
+ up = to_decimal(pl.unit_price) or D0
833
+ allocated_po_amount = rem_qty * up
834
+ raw_allocations.append((pl, allocated_po_amount))
835
+
836
+ po_sum = sum((amt for _, amt in raw_allocations), D0)
837
+ if po_sum == 0:
838
+ add_line_exception(
839
+ il.id,
840
+ MatchExceptionRecord(
841
+ exception_type="zero_price_po_line_at_match",
842
+ line_id=il.id,
843
+ exception_detail={
844
+ "reason": "po_sum_zero_in_allocation",
845
+ "group_gl_account": gl,
846
+ "po_line_ids": [pl.id for pl, _ in raw_allocations],
847
+ },
848
+ ),
849
+ )
850
+ continue
851
+
852
+ quant = Decimal("0.01")
853
+ alloc_rows: list[tuple[POLine, Decimal, Decimal]] = []
854
+ for pl, allocated_po_amount in raw_allocations:
855
+ allocated_inv_amount = (allocated_po_amount / po_sum * il_amount).quantize(quant)
856
+ alloc_rows.append((pl, allocated_po_amount, allocated_inv_amount))
857
+
858
+ allocated_inv_total = sum((row[2] for row in alloc_rows), D0)
859
+ residue = (il_amount - allocated_inv_total).quantize(quant)
860
+
861
+ if residue != 0:
862
+ # Largest allocated PO amount, tie-break by smallest po_line.id
863
+ target_idx = min(
864
+ range(len(alloc_rows)),
865
+ key=lambda i: (-alloc_rows[i][1], alloc_rows[i][0].id),
866
+ )
867
+ pl, a_po, a_inv = alloc_rows[target_idx]
868
+ alloc_rows[target_idx] = (pl, a_po, (a_inv + residue).quantize(quant))
869
+
870
+ group_line_zone = "green" if group_variance_pct <= price_green else ("amber" if group_variance_pct <= price_amber else "red")
871
+
872
+ for pl, allocated_po_amount, allocated_inv_amount in alloc_rows:
873
+ result.match_line_results.append(
874
+ MatchLineResult(
875
+ invoice_line_id=il.id,
876
+ po_line_id=pl.id,
877
+ match_tier=4,
878
+ allocated_inv_amount=allocated_inv_amount,
879
+ allocated_po_amount=allocated_po_amount.quantize(Decimal("0.01")),
880
+ price_var_pct=group_variance_pct,
881
+ qty_var_pct=None,
882
+ line_zone=group_line_zone,
883
+ line_status="matched",
884
+ match_type="two_way",
885
+ )
886
+ )
887
+
888
+ for pl in group_po_lines:
889
+ consume_po_line(pl.id)
890
+
891
+ continue
892
+
893
+ # Outside tolerance -> no_match for every invoice line in the group
894
+ for il in group_inv_lines:
895
+ add_line_exception(
896
+ il.id,
897
+ MatchExceptionRecord(
898
+ exception_type="no_match",
899
+ line_id=il.id,
900
+ exception_detail={
901
+ "reason": "tier4_outside_tolerance",
902
+ "group_gl_account": gl,
903
+ "invoice_sum": str(invoice_sum),
904
+ "po_sum": str(po_group_sum),
905
+ "variance_pct": str(group_variance_pct),
906
+ "po_line_ids": [pl.id for pl in group_po_lines],
907
+ },
908
+ ),
909
+ )
910
+
911
+ # ---------------------------------------------------------
912
+ # Final summary
913
+ # ---------------------------------------------------------
914
+ matched_line_ids = {r.invoice_line_id for r in result.match_line_results if r.line_status == "matched"}
915
+ result.lines_matched = len(matched_line_ids)
916
+
917
+ exception_line_ids = set(line_exception_map.keys())
918
+
919
+ # Any invoice line not matched and not explicitly captured is a no_match exception
920
+ for line in inv_lines:
921
+ if line.id not in matched_line_ids and line.id not in exception_line_ids:
922
+ exception_line_ids.add(line.id)
923
+
924
+ result.lines_in_exception = len(exception_line_ids)
925
+
926
+ # Final overall status and zone
927
+ if result.header_exceptions:
928
+ result.overall_status = "exception"
929
+ result.match_zone = "red"
930
+ else:
931
+ any_amber = any(r.line_zone == "amber" for r in result.match_line_results)
932
+ any_red = any(r.line_zone == "red" for r in result.match_line_results)
933
+
934
+ if result.lines_matched == result.lines_total and result.lines_in_exception == 0:
935
+ result.overall_status = "matched"
936
+ result.match_zone = "red" if any_red else ("amber" if any_amber else "green")
937
+ elif result.lines_matched > 0:
938
+ result.overall_status = "partial_match"
939
+ if any_red or result.match_exceptions:
940
+ result.match_zone = "red"
941
+ elif any_amber:
942
+ result.match_zone = "amber"
943
+ else:
944
+ result.match_zone = "green"
945
+ else:
946
+ result.overall_status = "exception"
947
+ result.match_zone = "red"
948
+
949
+ # documents.status is always pending_approval after task_match
950
+ result.documents_status = "pending_approval"
951
+ result.match_type = "two_way"
952
+
953
+ return result
954
+
955
+
956
+ # ============================================================
957
+ # Internal helper for Tier 1-3 finalization
958
+ # ============================================================
959
+ def _finalize_tier1_3_line_match(
960
+ *,
961
+ inv_line: InvoiceLine,
962
+ po_line: POLine,
963
+ tier: int,
964
+ cfg: TwoWayConfig,
965
+ ) -> tuple[bool, MatchLineResult]:
966
+ """
967
+ Finalize a Tier 1-3 match candidate.
968
+
969
+ Returns:
970
+ (matched_successfully, result_row)
971
+
972
+ If available_qty == 0, the line is blocked with po_ceiling_exhausted.
973
+ """
974
+ inv_qty = to_decimal(inv_line.quantity)
975
+ inv_price = to_decimal(inv_line.unit_price)
976
+ po_price = to_decimal(po_line.unit_price)
977
+
978
+ avail_qty = available_qty(po_line)
979
+ if avail_qty < 0:
980
+ return False, MatchLineResult(
981
+ invoice_line_id=inv_line.id,
982
+ po_line_id=po_line.id,
983
+ match_tier=tier,
984
+ line_zone="red",
985
+ line_status="exception",
986
+ match_type="two_way",
987
+ )
988
+
989
+ if avail_qty == 0:
990
+ return False, MatchLineResult(
991
+ invoice_line_id=inv_line.id,
992
+ po_line_id=po_line.id,
993
+ match_tier=tier,
994
+ price_var_pct=price_variance_pct(inv_price, po_price),
995
+ qty_var_pct=None,
996
+ line_zone="red",
997
+ line_status="exception",
998
+ match_type="two_way",
999
+ )
1000
+
1001
+ p_var = price_variance_pct(inv_price, po_price)
1002
+
1003
+ if inv_qty is None:
1004
+ q_var = None
1005
+ else:
1006
+ q_var = qty_over_pct(inv_qty, avail_qty)
1007
+
1008
+ item_type = (po_line.item_type or "services").lower()
1009
+ line_zone = zone_from_pct(p_var, q_var, item_type, cfg)
1010
+
1011
+ allocated_inv_amount = inv_line.amount if inv_line.amount is not None else None
1012
+ allocated_po_amount = ((to_decimal(po_line.quantity) or D0) - (to_decimal(po_line.invoiced_qty) or D0)) * (po_price or D0)
1013
+
1014
+ return True, MatchLineResult(
1015
+ invoice_line_id=inv_line.id,
1016
+ po_line_id=po_line.id,
1017
+ match_tier=tier,
1018
+ allocated_inv_amount=allocated_inv_amount,
1019
+ allocated_po_amount=allocated_po_amount.quantize(Decimal("0.01")),
1020
+ price_var_pct=p_var,
1021
+ qty_var_pct=q_var,
1022
+ line_zone=line_zone,
1023
+ line_status="matched",
1024
+ match_type="two_way",
1025
+ )