""" Invoice validation orchestrator. Loads platform_configs.json once, runs all seven validators in SDD order, wires outputs to downstream inputs, and returns a single consolidated result. No business logic lives here — all decisions remain inside the validator modules. """ import importlib.util import json import os # --------------------------------------------------------------------------- # Module loader — handles filenames that contain spaces or hyphens # --------------------------------------------------------------------------- def _load_fn(filename, fn_name): """Import a function from a .py file by path.""" here = os.path.dirname(os.path.abspath(__file__)) path = os.path.join(here, filename) spec = importlib.util.spec_from_file_location(filename, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return getattr(mod, fn_name) _run_vendor_validation = _load_fn("Vendor Validation.py", "run_vendor_validation") _run_tax_id_validation = _load_fn("Tax - ID validation.py", "run_tax_id_validation") _run_bank_validation = _load_fn("Bank validation.py", "run_bank_validation") _run_date_sanity_checks = _load_fn("date_sanity.py", "run_date_sanity_checks") _run_payment_terms_validation = _load_fn("payment terms valdiation.py", "run_payment_terms_validation") _run_tax_amount_validation = _load_fn("tax validation.py", "run_tax_amount_validation") _run_completeness_validation = _load_fn("completeness.py", "run_invoice_validation") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class _FieldProxy: """ Overlays computed attributes onto an existing extracted_fields object so downstream validators can read Tax-ID results via the normal getattr(extracted_fields, ...) pattern without mutating the original object. Only non-None values are overlaid; None values fall through to the base object (and its getattr default), which preserves the lenient defaults each validator defines for fields it cannot always receive. """ __slots__ = ("_base", "_overlay") def __init__(self, base, **overlay): object.__setattr__(self, "_base", base) object.__setattr__(self, "_overlay", {k: v for k, v in overlay.items() if v is not None}) def __getattr__(self, name): overlay = object.__getattribute__(self, "_overlay") if name in overlay: return overlay[name] return getattr(object.__getattribute__(self, "_base"), name) def _load_platform_configs(path=None): if path is None: here = os.path.dirname(os.path.abspath(__file__)) path = os.path.join(here, "platform_configs.json") with open(path, encoding="utf-8") as fh: return json.load(fh) def _collect(result, key): """Safely pull a list from a module result dict (returns [] if absent).""" return result.get(key) or [] # --------------------------------------------------------------------------- # Pipeline # --------------------------------------------------------------------------- def run_invoice_validation_pipeline( *, document, extracted_fields, vendors, vendor_bank_accounts, vendor_tax_ids, entity, line_items, purchase_orders, contracts, tax_master_rows, currency_seed_map, platform_configs=None, route_contexts=None, actor_id=None, ): """ Run all seven validation modules in SDD order and return a consolidated result. Parameters ---------- document : Invoice document object (needs .id, .tenant_id, .entity_id). extracted_fields : VLM-extracted fields object. vendors : Iterable of vendor records used for vendor matching. vendor_bank_accounts : Iterable of vendor bank account records. vendor_tax_ids : Iterable of vendor tax-ID records. entity : The routing entity object (needs .country_code, .vat_id, .region_code). line_items : Iterable of line-item records. purchase_orders : Iterable of purchase-order records. contracts : Iterable of contract records. tax_master_rows : Iterable of tax-master records. currency_seed_map : Dict mapping ISO currency code → row with minor_unit_value. platform_configs : Pre-loaded config dict; loaded from platform_configs.json when None. route_contexts : List of route-context strings forwarded to Vendor Validation. actor_id : Actor identifier forwarded to Vendor Validation for audit. Returns ------- { "risk_flags": [...], # aggregated, in execution order "advisory_flags": [...], # aggregated, in execution order "skipped_steps": [...], # aggregated, in execution order "ops_alerts": [...], # operational alerts (e.g. tax_master_unconfigured) "resolved_vendor_id": ..., "module_results": {...}, # raw return dict from each module, keyed by name } """ if platform_configs is None: platform_configs = _load_platform_configs() risk_flags = [] advisory_flags = [] skipped_steps = [] ops_alerts = [] module_results = {} # ------------------------------------------------------------------ # Step 1 — Vendor Validation # Runs first; produces resolved_vendor_id used by all downstream steps. # ------------------------------------------------------------------ vendor_result = _run_vendor_validation( document=document, extracted_fields=extracted_fields, vendors=vendors, platform_configs=platform_configs, route_contexts=route_contexts, actor_id=actor_id, ) module_results["vendor_validation"] = vendor_result risk_flags.extend(_collect(vendor_result, "risk_flags")) advisory_flags.extend(_collect(vendor_result, "advisory_flags")) skipped_steps.extend(_collect(vendor_result, "skipped_steps")) resolved_vendor_id = vendor_result.get("resolved_vendor_id") # Resolve the vendor object so downstream modules receive it directly. resolved_vendor = next( (v for v in vendors if getattr(v, "id", None) == resolved_vendor_id), None, ) if resolved_vendor_id else None # ------------------------------------------------------------------ # Step 2 — Tax ID Validation # Depends on resolved_vendor_id and the resolved vendor object. # ------------------------------------------------------------------ tax_id_result = _run_tax_id_validation( document=document, extracted_fields=extracted_fields, vendor=resolved_vendor, vendor_tax_ids=vendor_tax_ids, resolved_vendor_id=resolved_vendor_id, platform_configs=platform_configs, ) module_results["tax_id_validation"] = tax_id_result risk_flags.extend(_collect(tax_id_result, "risk_flags")) advisory_flags.extend(_collect(tax_id_result, "advisory_flags")) skipped_steps.extend(_collect(tax_id_result, "skipped_steps")) # Enrich extracted_fields with Tax-ID results so Tax Validation can read # sender_tax_id_inferred_family / _format_valid / _checksum_valid via its # normal getattr(extracted_fields, ...) pattern. enriched_fields = _FieldProxy( extracted_fields, sender_tax_id_inferred_family=tax_id_result.get("invoice_tax_id_inferred_type"), sender_tax_id_format_valid=tax_id_result.get("invoice_tax_id_format_valid"), sender_tax_id_checksum_valid=tax_id_result.get("invoice_tax_id_checksum_valid"), ) # ------------------------------------------------------------------ # Step 3 — Bank Validation # Depends on resolved_vendor_id. # ------------------------------------------------------------------ bank_result = _run_bank_validation( document=document, extracted_fields=extracted_fields, vendor_bank_accounts=vendor_bank_accounts, resolved_vendor_id=resolved_vendor_id, platform_configs=platform_configs, ) module_results["bank_validation"] = bank_result risk_flags.extend(_collect(bank_result, "risk_flags")) advisory_flags.extend(_collect(bank_result, "advisory_flags")) skipped_steps.extend(_collect(bank_result, "skipped_steps")) # ------------------------------------------------------------------ # Step 4 — Date Sanity Checks # Independent of vendor resolution. # ------------------------------------------------------------------ date_result = _run_date_sanity_checks( document=document, extracted_fields=extracted_fields, platform_configs=platform_configs, ) module_results["date_sanity"] = date_result risk_flags.extend(_collect(date_result, "risk_flags")) advisory_flags.extend(_collect(date_result, "advisory_flags")) skipped_steps.extend(_collect(date_result, "skipped_steps")) # ------------------------------------------------------------------ # Step 5 — Payment Terms Validation # Depends on resolved_vendor_id and the resolved vendor object. # ------------------------------------------------------------------ payment_terms_result = _run_payment_terms_validation( document=document, extracted_fields=extracted_fields, vendor=resolved_vendor, purchase_orders=purchase_orders, contracts=contracts, resolved_vendor_id=resolved_vendor_id, platform_configs=platform_configs, ) module_results["payment_terms_validation"] = payment_terms_result risk_flags.extend(_collect(payment_terms_result, "risk_flags")) advisory_flags.extend(_collect(payment_terms_result, "advisory_flags")) skipped_steps.extend(_collect(payment_terms_result, "skipped_steps")) # ------------------------------------------------------------------ # Step 6 — Tax Amount Validation # Depends on resolved vendor object and enriched_fields from Step 2. # ------------------------------------------------------------------ tax_result = _run_tax_amount_validation( document=document, extracted_fields=enriched_fields, line_items=line_items, vendor=resolved_vendor, entity=entity, tax_master_rows=tax_master_rows, platform_configs=platform_configs, ) module_results["tax_validation"] = tax_result risk_flags.extend(_collect(tax_result, "risk_flags")) advisory_flags.extend(_collect(tax_result, "advisory_flags")) skipped_steps.extend(_collect(tax_result, "skipped_steps")) ops_alerts.extend(_collect(tax_result, "ops_alerts")) # ------------------------------------------------------------------ # Step 7 — Completeness / Math Cross-check # Independent of vendor resolution. # ------------------------------------------------------------------ completeness_result = _run_completeness_validation( document=document, extracted_fields=extracted_fields, line_items=line_items, currency_seed_map=currency_seed_map, platform_configs=platform_configs, ) module_results["completeness"] = completeness_result risk_flags.extend(_collect(completeness_result, "risk_flags")) advisory_flags.extend(_collect(completeness_result, "advisory_flags")) skipped_steps.extend(_collect(completeness_result, "skipped_steps")) return { "risk_flags": risk_flags, "advisory_flags": advisory_flags, "skipped_steps": skipped_steps, "ops_alerts": ops_alerts, "resolved_vendor_id": resolved_vendor_id, "module_results": module_results, }