ledgershield / server /currency_engine.py
king673134's picture
Upload folder using huggingface_hub
007fbdd verified
Raw
History Blame Contribute Delete
7.83 kB
"""
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,
}
@dataclass
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}