Spaces:
Sleeping
Sleeping
| import re | |
| from datetime import date, datetime | |
| from hashlib import sha256 | |
| from unicodedata import normalize | |
| from decimal import Decimal | |
| from rapidfuzz import fuzz | |
| def run_bank_validation( | |
| *, | |
| document, | |
| extracted_fields, | |
| vendor_bank_accounts, | |
| resolved_vendor_id, | |
| platform_configs=None, | |
| name_match_fn=None, | |
| ): | |
| """ | |
| Bank validation logic based on Cat 2 section 7.4. | |
| Expected document fields: | |
| document.id | |
| document.tenant_id | |
| Expected extracted_fields fields: | |
| invoice_date | |
| currency | |
| bank_iban | |
| bank_acc_no | |
| bank_swift | |
| bank_acc_name | |
| Expected vendor_bank_accounts row fields: | |
| tenant_id | |
| vendor_id | |
| iban_canonical | |
| acc_no_canonical | |
| swift_bic | |
| bank_acc_name | |
| currency_code | |
| bank_country | |
| is_primary | |
| effective_from | |
| effective_to | |
| deleted_at | |
| name_match_fn: | |
| Optional function for bank account name comparison. | |
| Should return something like: | |
| {"decision": "matched" | "suggestion" | "unmatched"} | |
| """ | |
| platform_configs = platform_configs or {} | |
| iban_checksum_required = platform_configs.get( | |
| "validation.iban_checksum_required", | |
| True, | |
| ) | |
| iban_accno_exempt_countries = set( | |
| platform_configs.get( | |
| "validation.iban_accno_substring_exempt_countries", | |
| ["LC", "MT"], | |
| ) | |
| ) | |
| high_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_high_threshold", "0.90"))) | |
| medium_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_medium_threshold", "0.75"))) | |
| suggestion_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_suggestion_threshold", "0.60"))) | |
| def hash_prefix(value, length=8): | |
| if not value: | |
| return None | |
| return sha256(str(value).encode("utf-8")).hexdigest()[:length] | |
| def canonicalize_text(value): | |
| if value is None: | |
| return None | |
| value = normalize("NFKC", str(value)) | |
| value = value.strip().upper() | |
| return value or None | |
| def canonicalize_iban(value): | |
| if value is None: | |
| return None | |
| value = normalize("NFKC", str(value)) | |
| value = re.sub(r"[\s\-\.,/\\:;_]+", "", value) | |
| value = value.upper() | |
| return value or None | |
| def canonicalize_acc_no(value): | |
| if value is None: | |
| return None | |
| value = normalize("NFKC", str(value)) | |
| value = re.sub(r"[\s\-\.,/\\:;_]+", "", value) | |
| value = value.upper() | |
| return value or None | |
| def canonicalize_swift(value): | |
| if value is None: | |
| return None | |
| value = normalize("NFKC", str(value)) | |
| value = re.sub(r"\s+", "", value) | |
| value = value.upper() | |
| return value or None | |
| def iban_country(iban): | |
| if not iban or len(iban) < 2: | |
| return None | |
| return iban[:2] | |
| def iban_checksum_mod97_valid(iban): | |
| """ | |
| Standard IBAN mod-97 validation. | |
| Move first 4 chars to end. | |
| Replace letters A=10 ... Z=35. | |
| Result mod 97 must equal 1. | |
| """ | |
| if not iban or len(iban) < 4: | |
| return False | |
| rearranged = iban[4:] + iban[:4] | |
| numeric = "" | |
| for char in rearranged: | |
| if char.isdigit(): | |
| numeric += char | |
| elif "A" <= char <= "Z": | |
| numeric += str(ord(char) - ord("A") + 10) | |
| else: | |
| return False | |
| remainder = 0 | |
| for digit in numeric: | |
| remainder = (remainder * 10 + int(digit)) % 97 | |
| return remainder == 1 | |
| def iban_acc_no_consistent(iban, acc_no): | |
| """ | |
| Basic consistency check: | |
| remove country + check digits from IBAN, | |
| then check whether account number appears inside BBAN. | |
| Leading zeros are ignored for comparison. | |
| """ | |
| if not iban or not acc_no: | |
| return True | |
| country = iban_country(iban) | |
| if country in iban_accno_exempt_countries: | |
| return True | |
| bban = iban[4:] | |
| stripped_acc_no = acc_no.lstrip("0") or acc_no | |
| return stripped_acc_no in bban | |
| def is_effective(row, invoice_date): | |
| if getattr(row, "deleted_at", None): | |
| return False | |
| if invoice_date is None: | |
| return False | |
| effective_from = getattr(row, "effective_from", None) | |
| effective_to = getattr(row, "effective_to", None) | |
| if effective_from is not None and invoice_date < effective_from: | |
| return False | |
| if effective_to and invoice_date >= effective_to: | |
| return False | |
| return True | |
| def default_name_match(invoice_name, master_name): | |
| if not invoice_name or not master_name: | |
| return {"decision": "unmatched"} | |
| score = Decimal(str(fuzz.WRatio(invoice_name, master_name) / 100)).quantize( | |
| Decimal("0.0001") | |
| ) | |
| if score >= high_threshold: | |
| return {"decision": "matched", "score_tier": "high"} | |
| if score >= medium_threshold: | |
| return {"decision": "matched", "score_tier": "medium"} | |
| if score >= suggestion_threshold: | |
| return {"decision": "suggestion", "score_tier": "low"} | |
| return {"decision": "unmatched", "score_tier": "low"} | |
| def bank_account_name_matches(invoice_name, master_name): | |
| matcher = name_match_fn or default_name_match | |
| result = matcher( | |
| query_name=invoice_name, | |
| candidate_pool=[master_name], | |
| context={"purpose": "bank_acc_name_check"}, | |
| ) if name_match_fn else matcher(invoice_name, master_name) | |
| return result.get("decision") == "matched" | |
| def add_flag(code, *, field=None, weight=None, match_result="failed", invoice_value=None, | |
| master_value=None, currency_invoice=None, currency_master=None, | |
| primary_or_non_primary=None): | |
| flag = { | |
| "code": code, | |
| "field": field, | |
| "severity": "advisory", | |
| } | |
| if weight is not None: | |
| flag["weight"] = weight | |
| risk_flags.append(flag) | |
| audit_entries.append( | |
| { | |
| "field": field, | |
| "match_result": match_result, | |
| "invoice_hash_prefix": hash_prefix(invoice_value), | |
| "master_hash_prefix": hash_prefix(master_value), | |
| "currency_invoice": currency_invoice, | |
| "currency_master": currency_master, | |
| "primary_or_non_primary": primary_or_non_primary, | |
| } | |
| ) | |
| risk_flags = [] | |
| advisory_flags = [] | |
| skipped_steps = [] | |
| audit_entries = [] | |
| if resolved_vendor_id is None: | |
| skipped_steps.append( | |
| { | |
| "step": "bank_validation", | |
| "skip_reason": "vendor_unresolved", | |
| } | |
| ) | |
| return { | |
| "risk_flags": risk_flags, | |
| "advisory_flags": advisory_flags, | |
| "skipped_steps": skipped_steps, | |
| "audit_entries": audit_entries, | |
| "match_result": "skipped", | |
| } | |
| invoice_date = getattr(extracted_fields, "invoice_date", None) | |
| if isinstance(invoice_date, datetime): | |
| invoice_date = invoice_date.date() | |
| invoice_iban = canonicalize_iban(getattr(extracted_fields, "bank_iban", None)) | |
| invoice_acc_no = canonicalize_acc_no(getattr(extracted_fields, "bank_acc_no", None)) | |
| invoice_swift = canonicalize_swift(getattr(extracted_fields, "bank_swift", None)) | |
| invoice_bank_acc_name = getattr(extracted_fields, "bank_acc_name", None) | |
| invoice_currency = canonicalize_text(getattr(extracted_fields, "currency", None)) | |
| # 1. Invoice-side pre-format checks. | |
| if invoice_iban and iban_checksum_required: | |
| if not iban_checksum_mod97_valid(invoice_iban): | |
| add_flag( | |
| "bank_iban_invalid_checksum", | |
| field="bank_iban", | |
| weight=Decimal("0.35"), | |
| invoice_value=invoice_iban, | |
| ) | |
| if invoice_iban and invoice_acc_no: | |
| if not iban_acc_no_consistent(invoice_iban, invoice_acc_no): | |
| add_flag( | |
| "bank_iban_accno_inconsistent", | |
| field="bank_iban/bank_acc_no", | |
| weight=Decimal("0.35"), | |
| invoice_value=f"{invoice_iban}|{invoice_acc_no}", | |
| ) | |
| # 2. Build same-tenant, same-vendor, active candidate pool. | |
| # all_vendor_rows: non-deleted rows for this vendor, no date filtering. | |
| # Used for new_bank_details_no_master — distinguishes "no rows at all" from | |
| # "rows exist but none are currently effective". | |
| all_vendor_rows = [ | |
| row | |
| for row in vendor_bank_accounts | |
| if getattr(row, "tenant_id", None) == document.tenant_id | |
| and getattr(row, "vendor_id", None) == resolved_vendor_id | |
| and not getattr(row, "deleted_at", None) | |
| ] | |
| candidate_pool = [ | |
| row for row in all_vendor_rows | |
| if is_effective(row, invoice_date) | |
| ] | |
| candidate_pool.sort( | |
| key=lambda row: ( | |
| bool(getattr(row, "is_primary", False)), | |
| getattr(row, "effective_from", None) or date.max, | |
| ), | |
| reverse=True, | |
| ) | |
| primary_rows = [ | |
| row for row in candidate_pool | |
| if bool(getattr(row, "is_primary", False)) | |
| ] | |
| non_primary_rows = [ | |
| row for row in candidate_pool | |
| if not bool(getattr(row, "is_primary", False)) | |
| ] | |
| primary = primary_rows[0] if primary_rows else None | |
| invoice_has_any_bank_detail = any( | |
| [ | |
| invoice_iban, | |
| invoice_acc_no, | |
| invoice_swift, | |
| invoice_bank_acc_name, | |
| ] | |
| ) | |
| if invoice_has_any_bank_detail and not all_vendor_rows: | |
| add_flag( | |
| "new_bank_details_no_master", | |
| field="bank_details", | |
| invoice_value="bank_details_present", | |
| ) | |
| return { | |
| "risk_flags": risk_flags, | |
| "advisory_flags": advisory_flags, | |
| "skipped_steps": skipped_steps, | |
| "audit_entries": audit_entries, | |
| "match_result": "no_master_bank_details", | |
| } | |
| # 3. Compare against primary account. | |
| if primary: | |
| primary_match = True | |
| master_iban = getattr(primary, "iban_canonical", None) | |
| master_acc_no = getattr(primary, "acc_no_canonical", None) | |
| master_swift = canonicalize_swift(getattr(primary, "swift_bic", None)) | |
| master_bank_acc_name = getattr(primary, "bank_acc_name", None) | |
| master_currency = canonicalize_text(getattr(primary, "currency_code", None)) | |
| if invoice_iban: | |
| if not master_iban: | |
| primary_match = False | |
| add_flag( | |
| "new_bank_field_type_no_master", | |
| field="bank_iban", | |
| invoice_value=invoice_iban, | |
| primary_or_non_primary="primary", | |
| ) | |
| elif invoice_iban != master_iban: | |
| primary_match = False | |
| add_flag( | |
| "bank_iban_mismatch", | |
| field="bank_iban", | |
| invoice_value=invoice_iban, | |
| master_value=master_iban, | |
| primary_or_non_primary="primary", | |
| ) | |
| if invoice_acc_no and master_acc_no and invoice_acc_no != master_acc_no: | |
| primary_match = False | |
| add_flag( | |
| "bank_acc_no_mismatch", | |
| field="bank_acc_no", | |
| invoice_value=invoice_acc_no, | |
| master_value=master_acc_no, | |
| primary_or_non_primary="primary", | |
| ) | |
| if invoice_swift and master_swift and invoice_swift != master_swift: | |
| primary_match = False | |
| add_flag( | |
| "bank_swift_mismatch", | |
| field="bank_swift", | |
| invoice_value=invoice_swift, | |
| master_value=master_swift, | |
| primary_or_non_primary="primary", | |
| ) | |
| if invoice_bank_acc_name and master_bank_acc_name: | |
| if not bank_account_name_matches(invoice_bank_acc_name, master_bank_acc_name): | |
| primary_match = False | |
| add_flag( | |
| "bank_acc_name_mismatch", | |
| field="bank_acc_name", | |
| weight=Decimal("0.30"), | |
| invoice_value=invoice_bank_acc_name, | |
| master_value=master_bank_acc_name, | |
| primary_or_non_primary="primary", | |
| ) | |
| if invoice_currency and master_currency and invoice_currency != master_currency: | |
| primary_match = False | |
| add_flag( | |
| "bank_currency_mismatch", | |
| field="currency", | |
| weight=Decimal("0.20"), | |
| currency_invoice=invoice_currency, | |
| currency_master=master_currency, | |
| primary_or_non_primary="primary", | |
| ) | |
| match_result = "primary_match" if primary_match else "primary_mismatch" | |
| else: | |
| match_result = "no_primary_bank_account" | |
| secondary_match = None | |
| for row in non_primary_rows: | |
| if invoice_iban and getattr(row, "iban_canonical", None) == invoice_iban: | |
| secondary_match = row | |
| break | |
| if secondary_match: | |
| add_flag( | |
| "bank_non_primary_match", | |
| field="bank_iban", | |
| weight=Decimal("0.25"), | |
| match_result="matched_non_primary", | |
| invoice_value=invoice_iban, | |
| master_value=getattr(secondary_match, "iban_canonical", None), | |
| primary_or_non_primary="non_primary", | |
| ) | |
| return { | |
| "risk_flags": risk_flags, | |
| "advisory_flags": advisory_flags, | |
| "skipped_steps": skipped_steps, | |
| "audit_entries": audit_entries, | |
| "match_result": match_result, | |
| "candidate_count": len(candidate_pool), | |
| "primary_candidate_count": len(primary_rows), | |
| "non_primary_candidate_count": len(non_primary_rows), | |
| } |