"""Multi-currency support for border refaccionarias. Supports MXN and USD with configurable exchange rate per tenant. Rates are cached in Redis for 60 seconds to avoid repeated DB hits. Business rule: inventory prices are ALWAYS in MXN (base currency). Sales can be recorded in USD with conversion at checkout time. Accounting and CFDI always use MXN. """ from decimal import Decimal, ROUND_HALF_UP from config import DEFAULT_CURRENCY, EXCHANGE_RATE_USD_MXN from services.redis_stock_cache import _get_redis CURRENCIES = { 'MXN': {'symbol': '$', 'name': 'Peso Mexicano', 'name_en': 'Mexican Peso', 'decimals': 2}, 'USD': {'symbol': 'US$', 'name': 'Dolar Estadounidense', 'name_en': 'US Dollar', 'decimals': 2}, } # Cache TTL for exchange rates in Redis (seconds) _RATE_TTL = 60 def _to_dec(val): if val is None: return Decimal('0') return Decimal(str(val)) def get_exchange_rate(conn, from_currency, to_currency): """Get the exchange rate from tenant_config, with Redis cache. Returns: Decimal: rate such that amount * rate = converted amount (e.g., USD->MXN returns ~17.5, MXN->USD returns ~0.057) """ if from_currency == to_currency: return Decimal('1') cache_key = f"nexus:rate:{from_currency}:{to_currency}" # Try Redis first r = _get_redis() if r: try: cached = r.get(cache_key) if cached: return Decimal(str(cached)) except Exception: pass # Fallback: read from tenant_config DB rate = None if conn: try: cur = conn.cursor() cur.execute( "SELECT value FROM tenant_config WHERE key = 'exchange_rate_usd_mxn'" ) row = cur.fetchone() cur.close() if row and row[0]: rate = Decimal(str(row[0])) except Exception: pass if rate is None: rate = _to_dec(EXCHANGE_RATE_USD_MXN) # Compute cross rate if from_currency == 'USD' and to_currency == 'MXN': result = rate elif from_currency == 'MXN' and to_currency == 'USD': result = (Decimal('1') / rate).quantize(Decimal('0.000001'), rounding=ROUND_HALF_UP) else: result = Decimal('1') # Cache in Redis if r: try: r.set(cache_key, str(result), ex=_RATE_TTL) except Exception: pass return result def convert(amount, from_currency, to_currency, rate=None, conn=None): """Convert an amount between currencies. Args: amount: numeric amount to convert. from_currency: source currency code. to_currency: target currency code. rate: optional pre-computed rate (skips DB lookup). conn: optional DB connection to look up tenant rate. Returns: float: converted amount rounded to 2 decimals. """ if from_currency == to_currency: return float(amount) if rate is None: rate = get_exchange_rate(conn, from_currency, to_currency) amt = _to_dec(amount) rate_dec = _to_dec(rate) result = (amt * rate_dec).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) return float(result) def to_mxn(amount, currency, rate=None, conn=None): """Convert an amount to MXN (convenience wrapper).""" return convert(amount, currency, 'MXN', rate=rate, conn=conn) def from_mxn(amount, currency, rate=None, conn=None): """Convert an amount from MXN to target currency.""" return convert(amount, 'MXN', currency, rate=rate, conn=conn) def format_currency(amount, currency='MXN'): """Format an amount with the appropriate currency symbol. Returns: str: e.g. '$1,234.56' or 'US$1,234.56'. """ info = CURRENCIES.get(currency, CURRENCIES['MXN']) return f"{info['symbol']}{amount:,.{info['decimals']}f}" def get_currency_info(code=None): """Return currency metadata dict. If code is None, return all.""" if code: return CURRENCIES.get(code) return CURRENCIES.copy() def invalidate_rate_cache(): """Clear all cached exchange rates from Redis.""" r = _get_redis() if not r: return try: keys = r.keys('nexus:rate:*') if keys: r.delete(*keys) except Exception: pass