Spaces:
Sleeping
Sleeping
| """ | |
| Multi-currency processing engine for LedgerShield. | |
| Provides FX rate conversion, IBAN/SWIFT validation, currency mismatch | |
| detection, and multi-currency aging report generation. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| from .schema import normalize_text, safe_float | |
| FX_RATES: dict[str, float] = { | |
| "USD": 1.0, "EUR": 1.085, "GBP": 1.265, "JPY": 0.0067, "CHF": 1.12, | |
| "CAD": 0.74, "AUD": 0.655, "INR": 0.012, "CNY": 0.138, "SGD": 0.745, | |
| "HKD": 0.128, "KRW": 0.00075, "MXN": 0.058, "BRL": 0.20, "ZAR": 0.055, | |
| "AED": 0.2723, "SAR": 0.2667, "THB": 0.028, "SEK": 0.096, "NOK": 0.094, | |
| "DKK": 0.1455, "NZD": 0.61, "TRY": 0.031, "PLN": 0.25, "CZK": 0.044, | |
| } | |
| SANCTIONED_CURRENCIES: set[str] = {"KPW", "SYP", "IRR", "CUP"} | |
| FX_SPREAD_BPS = 50 | |
| _IBAN_LENGTHS: dict[str, int] = { | |
| "DE": 22, "GB": 22, "FR": 27, "IT": 27, "ES": 24, "NL": 18, | |
| "BE": 16, "AT": 20, "CH": 21, "SE": 24, "NO": 15, "DK": 18, | |
| "PL": 28, "CZ": 24, "IE": 22, "PT": 25, "FI": 18, "LU": 20, | |
| "GR": 27, "HU": 28, "RO": 24, "BG": 22, "HR": 21, "SK": 24, | |
| "SI": 19, "LT": 20, "LV": 21, "EE": 20, "AE": 23, "SA": 24, | |
| } | |
| class CurrencyResult: | |
| """Result of a currency conversion/validation operation.""" | |
| valid: bool = True | |
| source_currency: str = "" | |
| target_currency: str = "" | |
| source_amount: float = 0.0 | |
| converted_amount: float = 0.0 | |
| fx_rate: float = 1.0 | |
| spread_applied: bool = False | |
| warnings: list[str] = field(default_factory=list) | |
| risk_flags: list[str] = field(default_factory=list) | |
| def validate_iban(iban: str) -> dict[str, Any]: | |
| """Validate IBAN format: length check + MOD-97 checksum.""" | |
| cleaned = re.sub(r"\s+", "", str(iban or "")).upper() | |
| errors: list[str] = [] | |
| if len(cleaned) < 5: | |
| return {"valid": False, "country": "", "errors": ["IBAN too short"]} | |
| country_code = cleaned[:2] | |
| if not country_code.isalpha(): | |
| errors.append(f"Invalid country code: {country_code}") | |
| expected = _IBAN_LENGTHS.get(country_code) | |
| if expected and len(cleaned) != expected: | |
| errors.append(f"Expected {expected} chars for {country_code}, got {len(cleaned)}") | |
| rearranged = cleaned[4:] + cleaned[:4] | |
| numeric_str = "" | |
| for ch in rearranged: | |
| if ch.isdigit(): | |
| numeric_str += ch | |
| elif ch.isalpha(): | |
| numeric_str += str(ord(ch) - ord("A") + 10) | |
| else: | |
| errors.append(f"Invalid character: {ch}") | |
| if numeric_str and not errors: | |
| try: | |
| if int(numeric_str) % 97 != 1: | |
| errors.append("IBAN checksum failed (MOD-97)") | |
| except (ValueError, OverflowError): | |
| errors.append("IBAN numeric conversion failed") | |
| return {"valid": len(errors) == 0, "country": country_code, "errors": errors} | |
| def validate_swift(swift_code: str) -> dict[str, Any]: | |
| """Validate SWIFT/BIC code format (8 or 11 chars).""" | |
| cleaned = re.sub(r"\s+", "", str(swift_code or "")).upper() | |
| errors: list[str] = [] | |
| if len(cleaned) not in (8, 11): | |
| return {"valid": False, "bank_code": "", "country": "", | |
| "errors": [f"Must be 8 or 11 chars, got {len(cleaned)}"]} | |
| bank_code, country_code = cleaned[:4], cleaned[4:6] | |
| if not bank_code.isalpha(): | |
| errors.append(f"Bank code must be alphabetic: {bank_code}") | |
| if not country_code.isalpha(): | |
| errors.append(f"Country code must be alphabetic: {country_code}") | |
| return {"valid": len(errors) == 0, "bank_code": bank_code, | |
| "country": country_code, "errors": errors} | |
| def convert_currency(amount: float, from_currency: str, to_currency: str, | |
| apply_spread: bool = True) -> CurrencyResult: | |
| """Convert between currencies using static FX rates.""" | |
| from_code = str(from_currency or "").upper().strip() | |
| to_code = str(to_currency or "").upper().strip() | |
| warnings, risk_flags = [], [] | |
| from_rate = FX_RATES.get(from_code) | |
| to_rate = FX_RATES.get(to_code) | |
| for code in (from_code, to_code): | |
| if code in SANCTIONED_CURRENCIES: | |
| risk_flags.append(f"sanctioned_currency_{code.lower()}") | |
| if from_rate is None: | |
| return CurrencyResult(valid=False, source_currency=from_code, | |
| target_currency=to_code, source_amount=amount, | |
| warnings=[f"Unknown currency: {from_code}"], | |
| risk_flags=risk_flags) | |
| if to_rate is None: | |
| return CurrencyResult(valid=False, source_currency=from_code, | |
| target_currency=to_code, source_amount=amount, | |
| warnings=[f"Unknown currency: {to_code}"], | |
| risk_flags=risk_flags) | |
| raw_rate = to_rate / from_rate if from_rate > 0 else 0.0 | |
| if apply_spread: | |
| effective_rate = raw_rate * (1.0 - FX_SPREAD_BPS / 10000.0) | |
| warnings.append(f"Spread of {FX_SPREAD_BPS}bps applied") | |
| else: | |
| effective_rate = raw_rate | |
| converted = round(amount * effective_rate, 2) | |
| usd_amount = amount * from_rate | |
| if usd_amount > 100_000: | |
| warnings.append("Large-value cross-border payment: enhanced due diligence") | |
| return CurrencyResult(valid=True, source_currency=from_code, | |
| target_currency=to_code, source_amount=amount, | |
| converted_amount=converted, fx_rate=round(effective_rate, 6), | |
| spread_applied=apply_spread, warnings=warnings, | |
| risk_flags=risk_flags) | |
| def detect_currency_mismatch(invoice_currency: str, po_currency: str, | |
| payment_currency: str) -> dict[str, Any]: | |
| """Detect currency mismatches across invoice, PO, and payment.""" | |
| inv = normalize_text(invoice_currency).upper() | |
| po = normalize_text(po_currency).upper() | |
| pay = normalize_text(payment_currency).upper() | |
| mismatches = [] | |
| if inv and po and inv != po: | |
| mismatches.append(f"Invoice ({inv}) != PO ({po})") | |
| if inv and pay and inv != pay: | |
| mismatches.append(f"Invoice ({inv}) != Payment ({pay})") | |
| if po and pay and po != pay: | |
| mismatches.append(f"PO ({po}) != Payment ({pay})") | |
| risk = "high" if len(mismatches) >= 2 else ("medium" if mismatches else "none") | |
| return {"has_mismatch": bool(mismatches), "mismatches": mismatches, | |
| "risk_level": risk, "currencies_seen": sorted({c for c in (inv, po, pay) if c})} | |
| def generate_aging_report(invoices: list[dict[str, Any]], | |
| base_currency: str = "USD") -> dict[str, Any]: | |
| """Generate multi-currency aging report with 30/60/90/90+ buckets.""" | |
| buckets: dict[str, list[dict[str, Any]]] = { | |
| "0_30": [], "31_60": [], "61_90": [], "90_plus": []} | |
| total = 0.0 | |
| for inv in invoices: | |
| days = int(inv.get("days_outstanding", 0) or 0) | |
| amount = safe_float(inv.get("amount", 0)) | |
| curr = str(inv.get("currency", base_currency) or base_currency).upper() | |
| result = convert_currency(amount, curr, base_currency, apply_spread=False) | |
| base_amt = result.converted_amount if result.valid else amount | |
| entry = {"invoice_id": inv.get("invoice_id", ""), "vendor": inv.get("vendor_name", ""), | |
| "original_amount": amount, "currency": curr, | |
| "base_amount": round(base_amt, 2), "days": days} | |
| key = "0_30" if days <= 30 else "31_60" if days <= 60 else "61_90" if days <= 90 else "90_plus" | |
| buckets[key].append(entry) | |
| total += base_amt | |
| return {"base_currency": base_currency, "total": round(total, 2), | |
| "bucket_totals": {k: round(sum(e["base_amount"] for e in v), 2) | |
| for k, v in buckets.items()}, | |
| "buckets": buckets} | |