# SPEC-TASAS-CAMBIO-AUTOMATICAS ## Información del Documento | Campo | Valor | |-------|-------| | **Código Gap** | GAP-MGN-003-001 | | **Módulo** | MGN-003 (Configuración Multi-moneda) | | **Título** | Actualización Automática de Tasas de Cambio | | **Prioridad** | P1 | | **Complejidad** | Media | | **Referencia Odoo** | `base`, `currency_rate_live` | --- ## 1. Resumen Ejecutivo ### 1.1 Descripción del Gap El sistema requiere actualización automática de tasas de cambio desde proveedores externos (BCE, Banxico, Fed, etc.) para mantener conversiones precisas sin intervención manual diaria. ### 1.2 Justificación de Negocio - **Precisión financiera**: Tasas actualizadas para valoraciones correctas - **Eficiencia operativa**: Eliminación de actualización manual diaria - **Cumplimiento regulatorio**: Uso de tasas oficiales de bancos centrales - **Multi-mercado**: Soporte para operaciones en múltiples países ### 1.3 Alcance - Integración con proveedores de tasas (BCE, Banxico, BCB, etc.) - Programación de actualizaciones automáticas (cron) - Histórico de tasas con auditoría - Validación y alertas de anomalías - Cache inteligente para rendimiento --- ## 2. Análisis de Referencia (Odoo) ### 2.1 Modelo res.currency ```python # Odoo: odoo/addons/base/models/res_currency.py class Currency(models.Model): _name = 'res.currency' name = fields.Char(required=True) # ISO 4217 code symbol = fields.Char() rate = fields.Float(compute='_compute_current_rate', digits=(12, 6)) rate_ids = fields.One2many('res.currency.rate', 'currency_id') rounding = fields.Float(default=0.01) decimal_places = fields.Integer(compute='_compute_decimal_places') active = fields.Boolean(default=True) def _compute_current_rate(self): """Obtiene tasa vigente para fecha actual o específica""" date = self._context.get('date') or fields.Date.today() company = self.env.company for currency in self: currency.rate = self.env['res.currency.rate'].search([ ('currency_id', '=', currency.id), ('name', '<=', date), ('company_id', '=', company.id), ], order='name desc', limit=1).rate or 1.0 ``` ### 2.2 Modelo res.currency.rate ```python # Odoo: odoo/addons/base/models/res_currency.py class CurrencyRate(models.Model): _name = 'res.currency.rate' _order = 'name desc' name = fields.Date(default=fields.Date.today, required=True) # Effective date rate = fields.Float(digits=(12, 6), required=True, default=1.0) currency_id = fields.Many2one('res.currency', required=True) company_id = fields.Many2one('res.company') _sql_constraints = [ ('unique_name_per_day', 'unique(name, currency_id, company_id)', 'Only one rate per day per currency per company!') ] ``` ### 2.3 Proveedores de Tasas (currency_rate_live) ```python # Odoo: odoo/addons/currency_rate_live/models/res_config_settings.py CURRENCY_PROVIDER_SELECTION = [ ('ecb', 'European Central Bank'), ('fta', 'Federal Tax Administration (Switzerland)'), ('banxico', 'Bank of Mexico'), ('boc', 'Bank of Canada'), ('xe_com', 'xe.com'), ('bnr', 'National Bank of Romania'), ('mindicador', 'Chilean mindicador.cl'), ('bcrp', 'Bank of Peru'), ('cbuae', 'UAE Central Bank'), ('nbp', 'National Bank of Poland'), ('cbr', 'Central Bank of Russia'), ('tcmb', 'Central Bank of Turkey'), ('bcb', 'Central Bank of Brazil'), ] class ResCompany(models.Model): _inherit = 'res.company' currency_provider = fields.Selection(CURRENCY_PROVIDER_SELECTION) currency_interval_unit = fields.Selection([ ('manually', 'Manually'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly'), ], default='manually') currency_next_execution_date = fields.Date() ``` ### 2.4 Métodos de Actualización ```python # Ejemplo: Proveedor ECB def _parse_ecb_data(self, available_currencies): """Parse XML from European Central Bank""" url = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml' response = requests.get(url, timeout=30) root = ET.fromstring(response.content) data = {} for cube in root.iter('{http://www.ecb.int/vocabulary/2002-08-01/eurofxref}Cube'): currency = cube.attrib.get('currency') rate = cube.attrib.get('rate') if currency and rate: if currency in available_currencies: data[currency] = float(rate) data['EUR'] = 1.0 return data # Ejemplo: Proveedor Banxico def _parse_banxico_data(self, available_currencies): """Parse data from Bank of Mexico SOAP service""" url = 'https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF43718/datos/oportuno' headers = {'Bmx-Token': self.env.company.banxico_token} response = requests.get(url, headers=headers, timeout=30) data = response.json() rate = float(data['bmx']['series'][0]['datos'][0]['dato']) return {'USD': 1.0, 'MXN': rate} ``` ### 2.5 Scheduled Action (ir.cron) ```xml Currency Rate Update code model._cron_currency_rate_update() 1 days -1 ``` --- ## 3. Especificación Técnica ### 3.1 Modelo de Datos ```sql -- ===================================================== -- EXTENSIÓN DE MONEDAS -- ===================================================== -- Información adicional de moneda CREATE TABLE currency_configs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), currency_id UUID NOT NULL REFERENCES currencies(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, -- Configuración de decimales decimal_places INTEGER NOT NULL DEFAULT 2, rounding DECIMAL(10, 6) NOT NULL DEFAULT 0.01, -- Símbolo y formato symbol VARCHAR(10), symbol_position VARCHAR(10) DEFAULT 'after' CHECK (symbol_position IN ('before', 'after')), thousands_separator VARCHAR(3) DEFAULT ',', decimal_separator VARCHAR(3) DEFAULT '.', -- Estado is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES users(id), updated_by UUID REFERENCES users(id), CONSTRAINT uq_currency_config_tenant UNIQUE (currency_id, tenant_id) ); -- ===================================================== -- TASAS DE CAMBIO -- ===================================================== -- Tasas históricas de cambio CREATE TABLE currency_rates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), currency_id UUID NOT NULL REFERENCES currencies(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, -- Fecha efectiva y tasa effective_date DATE NOT NULL, rate DECIMAL(20, 10) NOT NULL CHECK (rate > 0), inverse_rate DECIMAL(20, 10) GENERATED ALWAYS AS (1.0 / rate) STORED, -- Origen de la tasa source rate_source_type NOT NULL DEFAULT 'manual', provider_id UUID REFERENCES currency_rate_providers(id), provider_reference VARCHAR(100), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES users(id), -- Restricción de unicidad: una tasa por día por moneda por tenant CONSTRAINT uq_currency_rate_day UNIQUE (currency_id, tenant_id, effective_date) ); -- Tipo de origen de tasa CREATE TYPE rate_source_type AS ENUM ( 'manual', -- Ingresada manualmente 'automatic', -- Descargada automáticamente 'imported', -- Importada desde archivo 'calculated' -- Calculada (cross-rate) ); -- Índices para búsqueda eficiente CREATE INDEX idx_currency_rates_lookup ON currency_rates(currency_id, tenant_id, effective_date DESC); CREATE INDEX idx_currency_rates_date ON currency_rates(effective_date DESC); -- ===================================================== -- PROVEEDORES DE TASAS -- ===================================================== CREATE TABLE currency_rate_providers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación code VARCHAR(50) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL, description TEXT, -- Tipo y configuración provider_type provider_type_enum NOT NULL, base_currency_id UUID NOT NULL REFERENCES currencies(id), -- Endpoint de API api_url VARCHAR(500) NOT NULL, api_method VARCHAR(10) DEFAULT 'GET', api_headers JSONB DEFAULT '{}', response_format VARCHAR(20) DEFAULT 'json' CHECK (response_format IN ('json', 'xml', 'csv')), -- Mapeo de respuesta (JSONPath o XPath) rate_path VARCHAR(200), -- Path al valor de tasa currency_path VARCHAR(200), -- Path al código de moneda date_path VARCHAR(200), -- Path a la fecha -- Monedas soportadas supported_currencies TEXT[], -- Array de códigos ISO -- Límites y throttling rate_limit_per_minute INTEGER DEFAULT 60, rate_limit_per_day INTEGER DEFAULT 1000, -- Estado is_active BOOLEAN NOT NULL DEFAULT TRUE, last_success_at TIMESTAMPTZ, last_error_at TIMESTAMPTZ, last_error_message TEXT, consecutive_failures INTEGER DEFAULT 0, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Tipos de proveedor CREATE TYPE provider_type_enum AS ENUM ( 'ecb', -- European Central Bank 'banxico', -- Banco de México 'bcb', -- Banco Central do Brasil 'bcrp', -- Banco Central de Reserva del Perú 'boc', -- Bank of Canada 'fed', -- Federal Reserve 'nbp', -- National Bank of Poland 'fixer', -- Fixer.io (comercial) 'openexchange', -- Open Exchange Rates 'xe', -- XE.com 'custom' -- Proveedor personalizado ); -- Proveedores predefinidos INSERT INTO currency_rate_providers (code, name, provider_type, base_currency_id, api_url, response_format, rate_path, currency_path) VALUES ('ecb', 'European Central Bank', 'ecb', (SELECT id FROM currencies WHERE iso_code = 'EUR'), 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml', 'xml', '//Cube[@currency]/@rate', '//Cube[@currency]/@currency'), ('banxico', 'Banco de México', 'banxico', (SELECT id FROM currencies WHERE iso_code = 'MXN'), 'https://www.banxico.org.mx/SieAPIRest/service/v1/series/{series}/datos/oportuno', 'json', '$.bmx.series[0].datos[0].dato', NULL), ('bcb', 'Banco Central do Brasil', 'bcb', (SELECT id FROM currencies WHERE iso_code = 'BRL'), 'https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/CotacaoDolarDia(dataCotacao=@date)?@date=%27{date}%27&$format=json', 'json', '$.value[0].cotacaoCompra', NULL); -- ===================================================== -- CONFIGURACIÓN POR TENANT -- ===================================================== CREATE TABLE tenant_currency_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, -- Moneda base del tenant base_currency_id UUID NOT NULL REFERENCES currencies(id), -- Proveedor de tasas preferido preferred_provider_id UUID REFERENCES currency_rate_providers(id), fallback_provider_id UUID REFERENCES currency_rate_providers(id), -- Programación de actualización update_interval update_interval_type NOT NULL DEFAULT 'daily', update_time TIME DEFAULT '06:00:00', -- Hora local de actualización update_timezone VARCHAR(50) DEFAULT 'UTC', next_scheduled_update TIMESTAMPTZ, -- Credenciales de API (encriptadas) api_credentials JSONB DEFAULT '{}', -- Configuración de validación max_rate_change_percent DECIMAL(5, 2) DEFAULT 10.00, -- Alerta si cambia >10% require_approval_above DECIMAL(5, 2) DEFAULT 20.00, -- Requiere aprobación si >20% -- Historial keep_history_days INTEGER DEFAULT 365, -- Días a mantener historial -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_tenant_currency_settings UNIQUE (tenant_id) ); -- Intervalo de actualización CREATE TYPE update_interval_type AS ENUM ( 'manually', -- Solo manual 'hourly', -- Cada hora 'daily', -- Diario 'weekly', -- Semanal (lunes) 'monthly' -- Mensual (día 1) ); -- ===================================================== -- LOG DE ACTUALIZACIONES -- ===================================================== CREATE TABLE currency_rate_update_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, provider_id UUID NOT NULL REFERENCES currency_rate_providers(id), -- Resultado status update_status_type NOT NULL, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), completed_at TIMESTAMPTZ, duration_ms INTEGER, -- Detalles currencies_requested INTEGER DEFAULT 0, currencies_updated INTEGER DEFAULT 0, currencies_failed INTEGER DEFAULT 0, -- Errores error_code VARCHAR(50), error_message TEXT, error_details JSONB, -- Request/Response para debugging request_url TEXT, request_headers JSONB, response_status INTEGER, response_body_sample TEXT, -- Primeros 1000 chars -- Índices para limpieza created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TYPE update_status_type AS ENUM ( 'pending', 'in_progress', 'success', 'partial_success', 'failed', 'timeout', 'rate_limited' ); -- Índice para limpieza de logs antiguos CREATE INDEX idx_rate_update_logs_cleanup ON currency_rate_update_logs(tenant_id, created_at); -- ===================================================== -- ALERTAS DE TASAS -- ===================================================== CREATE TABLE currency_rate_alerts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, currency_id UUID NOT NULL REFERENCES currencies(id), -- Tipo de alerta alert_type rate_alert_type NOT NULL, -- Valores detectados previous_rate DECIMAL(20, 10), new_rate DECIMAL(20, 10), change_percent DECIMAL(10, 4), -- Estado status alert_status_type NOT NULL DEFAULT 'pending', requires_approval BOOLEAN DEFAULT FALSE, -- Resolución resolved_at TIMESTAMPTZ, resolved_by UUID REFERENCES users(id), resolution_action VARCHAR(50), resolution_notes TEXT, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TYPE rate_alert_type AS ENUM ( 'large_change', -- Cambio mayor al umbral 'no_data', -- Sin datos del proveedor 'stale_rate', -- Tasa no actualizada en X días 'provider_error', -- Error del proveedor 'validation_failed', -- Validación de negocio fallida 'manual_override' -- Tasa sobrescrita manualmente ); CREATE TYPE alert_status_type AS ENUM ( 'pending', 'acknowledged', 'approved', 'rejected', 'auto_resolved' ); ``` ### 3.2 Servicios de Dominio ```python # app/modules/multi_currency/domain/services/currency_rate_service.py from datetime import date, datetime, time, timedelta from decimal import Decimal from typing import Optional, Dict, List from enum import Enum import httpx import asyncio from xml.etree import ElementTree as ET from app.core.exceptions import DomainException from app.core.logging import get_logger logger = get_logger(__name__) class RateSourceType(str, Enum): MANUAL = "manual" AUTOMATIC = "automatic" IMPORTED = "imported" CALCULATED = "calculated" class CurrencyRateService: """Servicio de dominio para gestión de tasas de cambio.""" def __init__( self, rate_repository: "CurrencyRateRepository", provider_repository: "CurrencyRateProviderRepository", settings_repository: "TenantCurrencySettingsRepository", alert_service: "CurrencyRateAlertService", cache_service: "CacheService" ): self._rate_repo = rate_repository self._provider_repo = provider_repository self._settings_repo = settings_repository self._alert_service = alert_service self._cache = cache_service async def get_rate( self, tenant_id: str, currency_id: str, target_date: Optional[date] = None, use_cache: bool = True ) -> Decimal: """ Obtiene la tasa de cambio vigente para una moneda en una fecha. Args: tenant_id: ID del tenant currency_id: ID de la moneda target_date: Fecha para la tasa (default: hoy) use_cache: Si usar cache (default: True) Returns: Tasa de cambio (rate relativo a moneda base del tenant) """ target_date = target_date or date.today() # Verificar cache if use_cache: cache_key = f"rate:{tenant_id}:{currency_id}:{target_date}" cached = await self._cache.get(cache_key) if cached: return Decimal(cached) # Buscar tasa vigente (la más reciente <= target_date) rate = await self._rate_repo.find_effective_rate( tenant_id=tenant_id, currency_id=currency_id, effective_date=target_date ) if not rate: # Verificar si es la moneda base settings = await self._settings_repo.get_by_tenant(tenant_id) if settings and settings.base_currency_id == currency_id: return Decimal("1.0") raise DomainException( code="RATE_NOT_FOUND", message=f"No exchange rate found for currency {currency_id} on {target_date}" ) # Guardar en cache (expira a medianoche) if use_cache: ttl = self._calculate_ttl_to_midnight() await self._cache.set(cache_key, str(rate.rate), ttl=ttl) return rate.rate async def get_rate_between( self, tenant_id: str, from_currency_id: str, to_currency_id: str, target_date: Optional[date] = None ) -> Decimal: """ Obtiene la tasa de conversión entre dos monedas. Usa cross-rate a través de la moneda base si es necesario. """ target_date = target_date or date.today() # Obtener tasas de ambas monedas respecto a moneda base from_rate = await self.get_rate(tenant_id, from_currency_id, target_date) to_rate = await self.get_rate(tenant_id, to_currency_id, target_date) # Cross-rate: to_rate / from_rate if from_rate == Decimal("0"): raise DomainException( code="INVALID_RATE", message=f"Invalid zero rate for currency {from_currency_id}" ) return to_rate / from_rate async def convert_amount( self, tenant_id: str, amount: Decimal, from_currency_id: str, to_currency_id: str, target_date: Optional[date] = None, round_result: bool = True ) -> Decimal: """ Convierte un monto entre dos monedas. Args: tenant_id: ID del tenant amount: Monto a convertir from_currency_id: Moneda origen to_currency_id: Moneda destino target_date: Fecha para la tasa round_result: Si redondear según decimales de moneda destino Returns: Monto convertido """ if from_currency_id == to_currency_id: return amount rate = await self.get_rate_between( tenant_id, from_currency_id, to_currency_id, target_date ) converted = amount * rate if round_result: # Obtener decimales de moneda destino currency_config = await self._get_currency_config(tenant_id, to_currency_id) decimal_places = currency_config.decimal_places if currency_config else 2 converted = round(converted, decimal_places) return converted async def set_rate_manual( self, tenant_id: str, currency_id: str, rate: Decimal, effective_date: date, user_id: str, notes: Optional[str] = None ) -> "CurrencyRate": """Establece una tasa manualmente.""" # Validar que no sea la moneda base settings = await self._settings_repo.get_by_tenant(tenant_id) if settings and settings.base_currency_id == currency_id: raise DomainException( code="CANNOT_SET_BASE_RATE", message="Cannot set rate for base currency" ) # Verificar cambio significativo current_rate = await self._rate_repo.find_effective_rate( tenant_id, currency_id, effective_date ) if current_rate: change_percent = abs((rate - current_rate.rate) / current_rate.rate * 100) if change_percent > settings.max_rate_change_percent: # Crear alerta await self._alert_service.create_alert( tenant_id=tenant_id, currency_id=currency_id, alert_type="manual_override", previous_rate=current_rate.rate, new_rate=rate, change_percent=change_percent, requires_approval=change_percent > settings.require_approval_above ) # Crear o actualizar tasa new_rate = await self._rate_repo.upsert( tenant_id=tenant_id, currency_id=currency_id, effective_date=effective_date, rate=rate, source=RateSourceType.MANUAL, created_by=user_id ) # Invalidar cache await self._invalidate_rate_cache(tenant_id, currency_id, effective_date) logger.info( "Manual rate set", tenant_id=tenant_id, currency_id=currency_id, rate=str(rate), date=str(effective_date), user_id=user_id ) return new_rate async def get_rate_history( self, tenant_id: str, currency_id: str, date_from: date, date_to: date ) -> List["CurrencyRate"]: """Obtiene historial de tasas para un período.""" return await self._rate_repo.find_by_date_range( tenant_id=tenant_id, currency_id=currency_id, date_from=date_from, date_to=date_to ) def _calculate_ttl_to_midnight(self) -> int: """Calcula segundos hasta medianoche.""" now = datetime.now() midnight = datetime.combine(now.date() + timedelta(days=1), time.min) return int((midnight - now).total_seconds()) async def _get_currency_config( self, tenant_id: str, currency_id: str ) -> Optional["CurrencyConfig"]: """Obtiene configuración de moneda para tenant.""" # Implementación del repositorio pass async def _invalidate_rate_cache( self, tenant_id: str, currency_id: str, effective_date: date ): """Invalida cache de tasa.""" cache_key = f"rate:{tenant_id}:{currency_id}:{effective_date}" await self._cache.delete(cache_key) class CurrencyRateUpdateService: """Servicio para actualización automática de tasas.""" def __init__( self, provider_registry: "ProviderRegistry", rate_service: CurrencyRateService, settings_repository: "TenantCurrencySettingsRepository", log_repository: "UpdateLogRepository", alert_service: "CurrencyRateAlertService" ): self._providers = provider_registry self._rate_service = rate_service self._settings_repo = settings_repository self._log_repo = log_repository self._alert_service = alert_service async def update_rates_for_tenant( self, tenant_id: str, currencies: Optional[List[str]] = None, force: bool = False ) -> "UpdateResult": """ Actualiza tasas de cambio para un tenant. Args: tenant_id: ID del tenant currencies: Lista de códigos ISO a actualizar (None = todas activas) force: Forzar actualización aunque ya exista tasa del día Returns: Resultado de la actualización """ settings = await self._settings_repo.get_by_tenant(tenant_id) if not settings: raise DomainException( code="SETTINGS_NOT_FOUND", message=f"Currency settings not found for tenant {tenant_id}" ) # Obtener proveedor provider = self._providers.get(settings.preferred_provider_id) if not provider: raise DomainException( code="PROVIDER_NOT_FOUND", message="No currency rate provider configured" ) # Crear log de inicio log = await self._log_repo.create( tenant_id=tenant_id, provider_id=provider.id, status="in_progress" ) start_time = datetime.utcnow() result = UpdateResult() try: # Obtener tasas del proveedor rates_data = await provider.fetch_rates( currencies=currencies, credentials=settings.api_credentials ) result.currencies_requested = len(rates_data) # Procesar cada tasa for currency_code, rate_value in rates_data.items(): try: await self._process_rate( tenant_id=tenant_id, currency_code=currency_code, rate_value=rate_value, provider_id=provider.id, settings=settings, force=force ) result.currencies_updated += 1 except Exception as e: result.currencies_failed += 1 result.errors.append(f"{currency_code}: {str(e)}") # Determinar estado final if result.currencies_failed == 0: result.status = "success" elif result.currencies_updated > 0: result.status = "partial_success" else: result.status = "failed" except Exception as e: result.status = "failed" result.error_message = str(e) logger.exception("Rate update failed", tenant_id=tenant_id) # Intentar con proveedor de respaldo if settings.fallback_provider_id: try: result = await self._try_fallback_provider( tenant_id, settings, currencies ) except Exception as fallback_error: logger.exception("Fallback provider also failed") # Actualizar log end_time = datetime.utcnow() await self._log_repo.update( log_id=log.id, status=result.status, completed_at=end_time, duration_ms=int((end_time - start_time).total_seconds() * 1000), currencies_requested=result.currencies_requested, currencies_updated=result.currencies_updated, currencies_failed=result.currencies_failed, error_message=result.error_message ) # Programar siguiente actualización await self._schedule_next_update(tenant_id, settings) return result async def _process_rate( self, tenant_id: str, currency_code: str, rate_value: Decimal, provider_id: str, settings: "TenantCurrencySettings", force: bool ): """Procesa y guarda una tasa individual.""" today = date.today() # Verificar si ya existe tasa del día if not force: existing = await self._rate_service._rate_repo.find_by_date( tenant_id, currency_code, today ) if existing: logger.debug(f"Rate for {currency_code} already exists for {today}") return # Obtener tasa anterior para validación previous = await self._rate_service._rate_repo.find_effective_rate( tenant_id, currency_code, today - timedelta(days=1) ) # Validar cambio if previous: change_percent = abs((rate_value - previous.rate) / previous.rate * 100) if change_percent > settings.max_rate_change_percent: # Crear alerta pero continuar guardando await self._alert_service.create_alert( tenant_id=tenant_id, currency_code=currency_code, alert_type="large_change", previous_rate=previous.rate, new_rate=rate_value, change_percent=change_percent, requires_approval=change_percent > settings.require_approval_above ) # Guardar tasa await self._rate_service._rate_repo.upsert( tenant_id=tenant_id, currency_code=currency_code, effective_date=today, rate=rate_value, source=RateSourceType.AUTOMATIC, provider_id=provider_id ) async def _schedule_next_update( self, tenant_id: str, settings: "TenantCurrencySettings" ): """Calcula y guarda la próxima fecha de actualización.""" now = datetime.utcnow() if settings.update_interval == "hourly": next_update = now + timedelta(hours=1) elif settings.update_interval == "daily": # Próximo día a la hora configurada next_date = now.date() + timedelta(days=1) next_update = datetime.combine(next_date, settings.update_time) elif settings.update_interval == "weekly": # Próximo lunes days_until_monday = (7 - now.weekday()) % 7 or 7 next_date = now.date() + timedelta(days=days_until_monday) next_update = datetime.combine(next_date, settings.update_time) elif settings.update_interval == "monthly": # Primer día del próximo mes if now.month == 12: next_date = date(now.year + 1, 1, 1) else: next_date = date(now.year, now.month + 1, 1) next_update = datetime.combine(next_date, settings.update_time) else: next_update = None await self._settings_repo.update_next_scheduled(tenant_id, next_update) class UpdateResult: """Resultado de actualización de tasas.""" def __init__(self): self.status: str = "pending" self.currencies_requested: int = 0 self.currencies_updated: int = 0 self.currencies_failed: int = 0 self.errors: List[str] = [] self.error_message: Optional[str] = None ``` ### 3.3 Proveedores de Tasas ```python # app/modules/multi_currency/infrastructure/providers/base.py from abc import ABC, abstractmethod from decimal import Decimal from typing import Dict, List, Optional from datetime import date class CurrencyRateProvider(ABC): """Clase base para proveedores de tasas de cambio.""" @property @abstractmethod def code(self) -> str: """Código único del proveedor.""" pass @property @abstractmethod def name(self) -> str: """Nombre descriptivo.""" pass @property @abstractmethod def base_currency(self) -> str: """Moneda base del proveedor (ISO 4217).""" pass @property @abstractmethod def supported_currencies(self) -> List[str]: """Lista de monedas soportadas.""" pass @abstractmethod async def fetch_rates( self, currencies: Optional[List[str]] = None, target_date: Optional[date] = None, credentials: Optional[Dict] = None ) -> Dict[str, Decimal]: """ Obtiene tasas de cambio del proveedor. Args: currencies: Monedas específicas a obtener (None = todas) target_date: Fecha para tasas históricas (None = hoy) credentials: Credenciales de API si requeridas Returns: Dict con código de moneda -> tasa relativa a base_currency """ pass # app/modules/multi_currency/infrastructure/providers/ecb_provider.py import httpx from xml.etree import ElementTree as ET from decimal import Decimal from typing import Dict, List, Optional from datetime import date from .base import CurrencyRateProvider class ECBProvider(CurrencyRateProvider): """ Proveedor de tasas del Banco Central Europeo. - Base: EUR - Actualización: Diaria ~16:00 CET - Sin autenticación requerida - ~32 monedas soportadas """ DAILY_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml" HISTORY_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml" # Namespace del XML del ECB NS = {'gesmes': 'http://www.gesmes.org/xml/2002-08-01', 'eurofxref': 'http://www.ecb.int/vocabulary/2002-08-01/eurofxref'} @property def code(self) -> str: return "ecb" @property def name(self) -> str: return "European Central Bank" @property def base_currency(self) -> str: return "EUR" @property def supported_currencies(self) -> List[str]: return [ "USD", "JPY", "BGN", "CZK", "DKK", "GBP", "HUF", "PLN", "RON", "SEK", "CHF", "ISK", "NOK", "TRY", "AUD", "BRL", "CAD", "CNY", "HKD", "IDR", "ILS", "INR", "KRW", "MXN", "MYR", "NZD", "PHP", "SGD", "THB", "ZAR" ] async def fetch_rates( self, currencies: Optional[List[str]] = None, target_date: Optional[date] = None, credentials: Optional[Dict] = None ) -> Dict[str, Decimal]: """Obtiene tasas del BCE.""" url = self.DAILY_URL if not target_date else self.HISTORY_URL async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(url) response.raise_for_status() rates = self._parse_xml(response.content, target_date) # Filtrar monedas si se especificaron if currencies: rates = {k: v for k, v in rates.items() if k in currencies} # EUR siempre es 1.0 rates["EUR"] = Decimal("1.0") return rates def _parse_xml( self, content: bytes, target_date: Optional[date] = None ) -> Dict[str, Decimal]: """Parsea respuesta XML del BCE.""" root = ET.fromstring(content) rates = {} # Buscar el Cube con los datos for time_cube in root.findall('.//eurofxref:Cube[@time]', self.NS): cube_date = time_cube.get('time') # Si buscamos fecha específica, verificar if target_date and cube_date != str(target_date): continue for rate_cube in time_cube.findall('eurofxref:Cube', self.NS): currency = rate_cube.get('currency') rate = rate_cube.get('rate') if currency and rate: rates[currency] = Decimal(rate) # Si encontramos datos (o es el más reciente), salir if rates: break return rates # app/modules/multi_currency/infrastructure/providers/banxico_provider.py class BanxicoProvider(CurrencyRateProvider): """ Proveedor de tasas del Banco de México. - Base: MXN - Requiere token de API - Series disponibles: USD/MXN, EUR/MXN, etc. """ BASE_URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series" # Series del SIE para diferentes monedas SERIES = { "USD": "SF43718", # Tipo de cambio pesos por dólar E.U.A. "EUR": "SF46410", # Tipo de cambio pesos por euro "GBP": "SF46407", # Tipo de cambio pesos por libra esterlina "JPY": "SF46406", # Tipo de cambio pesos por yen japonés "CAD": "SF60632", # Tipo de cambio pesos por dólar canadiense } @property def code(self) -> str: return "banxico" @property def name(self) -> str: return "Banco de México" @property def base_currency(self) -> str: return "MXN" @property def supported_currencies(self) -> List[str]: return list(self.SERIES.keys()) async def fetch_rates( self, currencies: Optional[List[str]] = None, target_date: Optional[date] = None, credentials: Optional[Dict] = None ) -> Dict[str, Decimal]: """Obtiene tasas de Banxico.""" if not credentials or 'token' not in credentials: raise ValueError("Banxico API token required") token = credentials['token'] currencies = currencies or list(self.SERIES.keys()) # Construir lista de series a consultar series_ids = [self.SERIES[c] for c in currencies if c in self.SERIES] series_param = ",".join(series_ids) url = f"{self.BASE_URL}/{series_param}/datos/oportuno" headers = {"Bmx-Token": token} async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(url, headers=headers) response.raise_for_status() data = response.json() rates = self._parse_response(data, currencies) # MXN siempre es 1.0 rates["MXN"] = Decimal("1.0") return rates def _parse_response( self, data: Dict, currencies: List[str] ) -> Dict[str, Decimal]: """Parsea respuesta JSON de Banxico.""" rates = {} series_list = data.get("bmx", {}).get("series", []) for series in series_list: series_id = series.get("idSerie") # Encontrar moneda correspondiente currency = None for curr, sid in self.SERIES.items(): if sid == series_id: currency = curr break if currency and currency in currencies: datos = series.get("datos", []) if datos: # Tomar el dato más reciente rate_str = datos[0].get("dato", "").replace(",", "") if rate_str and rate_str != "N/E": rates[currency] = Decimal(rate_str) return rates # app/modules/multi_currency/infrastructure/providers/bcb_provider.py class BCBProvider(CurrencyRateProvider): """ Proveedor de tasas del Banco Central do Brasil. - Base: BRL - API PTAX (tasa oficial) - Sin autenticación """ BASE_URL = "https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata" @property def code(self) -> str: return "bcb" @property def name(self) -> str: return "Banco Central do Brasil" @property def base_currency(self) -> str: return "BRL" @property def supported_currencies(self) -> List[str]: return ["USD", "EUR", "GBP", "JPY", "CHF", "AUD", "CAD"] async def fetch_rates( self, currencies: Optional[List[str]] = None, target_date: Optional[date] = None, credentials: Optional[Dict] = None ) -> Dict[str, Decimal]: """Obtiene tasas PTAX del BCB.""" target_date = target_date or date.today() date_str = target_date.strftime("%m-%d-%Y") rates = {} currencies = currencies or self.supported_currencies async with httpx.AsyncClient(timeout=30.0) as client: for currency in currencies: if currency not in self.supported_currencies: continue url = ( f"{self.BASE_URL}/CotacaoMoedaDia(moeda=@moeda,dataCotacao=@data)" f"?@moeda='{currency}'&@data='{date_str}'&$format=json" ) try: response = await client.get(url) response.raise_for_status() data = response.json() values = data.get("value", []) if values: # Usar cotação de compra (buying rate) rate = values[-1].get("cotacaoCompra") if rate: rates[currency] = Decimal(str(rate)) except Exception as e: # Log error but continue with other currencies pass # BRL siempre es 1.0 rates["BRL"] = Decimal("1.0") return rates # app/modules/multi_currency/infrastructure/providers/registry.py class ProviderRegistry: """Registro de proveedores de tasas de cambio.""" def __init__(self): self._providers: Dict[str, CurrencyRateProvider] = {} self._register_defaults() def _register_defaults(self): """Registra proveedores predeterminados.""" self.register(ECBProvider()) self.register(BanxicoProvider()) self.register(BCBProvider()) def register(self, provider: CurrencyRateProvider): """Registra un proveedor.""" self._providers[provider.code] = provider def get(self, code: str) -> Optional[CurrencyRateProvider]: """Obtiene un proveedor por código.""" return self._providers.get(code) def get_by_id(self, provider_id: str) -> Optional[CurrencyRateProvider]: """Obtiene un proveedor por ID de base de datos.""" # Buscar en DB y retornar proveedor correspondiente pass def list_all(self) -> List[CurrencyRateProvider]: """Lista todos los proveedores registrados.""" return list(self._providers.values()) ``` ### 3.4 Scheduler (Cron Jobs) ```python # app/modules/multi_currency/infrastructure/scheduler/rate_update_scheduler.py from datetime import datetime, timedelta from typing import List, Optional import asyncio from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger from app.core.logging import get_logger from app.modules.multi_currency.domain.services.currency_rate_service import ( CurrencyRateUpdateService ) logger = get_logger(__name__) class RateUpdateScheduler: """ Programador de actualizaciones de tasas de cambio. Ejecuta actualizaciones automáticas según configuración de cada tenant. """ def __init__( self, update_service: CurrencyRateUpdateService, tenant_service: "TenantService" ): self._update_service = update_service self._tenant_service = tenant_service self._scheduler = AsyncIOScheduler() self._running = False async def start(self): """Inicia el scheduler.""" if self._running: return # Job maestro que verifica tenants pendientes cada 5 minutos self._scheduler.add_job( self._check_pending_updates, trigger=IntervalTrigger(minutes=5), id="rate_update_checker", name="Currency Rate Update Checker", replace_existing=True ) # Job de limpieza de logs antiguos (diario a las 3 AM) self._scheduler.add_job( self._cleanup_old_logs, trigger=CronTrigger(hour=3, minute=0), id="rate_log_cleanup", name="Rate Update Log Cleanup", replace_existing=True ) self._scheduler.start() self._running = True logger.info("Rate update scheduler started") async def stop(self): """Detiene el scheduler.""" if not self._running: return self._scheduler.shutdown(wait=True) self._running = False logger.info("Rate update scheduler stopped") async def _check_pending_updates(self): """ Verifica y ejecuta actualizaciones pendientes. Busca tenants cuya próxima actualización programada ha pasado y ejecuta la actualización de tasas. """ try: now = datetime.utcnow() # Obtener tenants con actualización pendiente pending_tenants = await self._tenant_service.get_tenants_pending_rate_update( before=now ) logger.info( f"Found {len(pending_tenants)} tenants pending rate update" ) # Procesar cada tenant (con límite de concurrencia) semaphore = asyncio.Semaphore(5) # Máximo 5 concurrentes async def update_with_limit(tenant_id: str): async with semaphore: await self._update_tenant_rates(tenant_id) await asyncio.gather(*[ update_with_limit(t.id) for t in pending_tenants ]) except Exception as e: logger.exception("Error checking pending rate updates") async def _update_tenant_rates(self, tenant_id: str): """Ejecuta actualización de tasas para un tenant.""" try: logger.info(f"Starting rate update for tenant {tenant_id}") result = await self._update_service.update_rates_for_tenant(tenant_id) logger.info( f"Rate update completed for tenant {tenant_id}", status=result.status, updated=result.currencies_updated, failed=result.currencies_failed ) except Exception as e: logger.exception( f"Rate update failed for tenant {tenant_id}", error=str(e) ) async def _cleanup_old_logs(self): """Limpia logs de actualización antiguos.""" try: # Obtener configuración de retención por tenant tenants = await self._tenant_service.get_all_active() for tenant in tenants: settings = await self._update_service._settings_repo.get_by_tenant( tenant.id ) if settings: cutoff_date = datetime.utcnow() - timedelta( days=settings.keep_history_days ) deleted = await self._update_service._log_repo.delete_before( tenant_id=tenant.id, before=cutoff_date ) if deleted > 0: logger.info( f"Deleted {deleted} old rate update logs", tenant_id=tenant.id ) except Exception as e: logger.exception("Error cleaning up old rate logs") async def trigger_immediate_update( self, tenant_id: str, currencies: Optional[List[str]] = None ): """Dispara actualización inmediata para un tenant.""" logger.info( f"Triggering immediate rate update for tenant {tenant_id}", currencies=currencies ) return await self._update_service.update_rates_for_tenant( tenant_id=tenant_id, currencies=currencies, force=True ) ``` ### 3.5 API REST ```python # app/modules/multi_currency/api/v1/currency_rates.py from datetime import date from decimal import Decimal from typing import List, Optional from fastapi import APIRouter, Depends, Query, Path, HTTPException, status from pydantic import BaseModel, Field from app.core.auth import get_current_user, require_permissions from app.core.pagination import PaginatedResponse, PaginationParams from app.modules.multi_currency.domain.services.currency_rate_service import ( CurrencyRateService, CurrencyRateUpdateService ) router = APIRouter(prefix="/currency-rates", tags=["Currency Rates"]) # ===================================================== # SCHEMAS # ===================================================== class CurrencyRateResponse(BaseModel): """Respuesta de tasa de cambio.""" currency_id: str currency_code: str currency_name: str rate: Decimal inverse_rate: Decimal effective_date: date source: str provider_name: Optional[str] = None class CurrencyRateHistoryResponse(BaseModel): """Respuesta de historial de tasas.""" currency_id: str currency_code: str rates: List[CurrencyRateResponse] class ConversionRequest(BaseModel): """Request para conversión de moneda.""" amount: Decimal = Field(..., gt=0) from_currency_id: str to_currency_id: str date: Optional[date] = None class ConversionResponse(BaseModel): """Respuesta de conversión.""" original_amount: Decimal from_currency: str converted_amount: Decimal to_currency: str rate_used: Decimal rate_date: date class SetRateRequest(BaseModel): """Request para establecer tasa manual.""" currency_id: str rate: Decimal = Field(..., gt=0) effective_date: date notes: Optional[str] = None class UpdateRatesRequest(BaseModel): """Request para actualizar tasas.""" currencies: Optional[List[str]] = None # Códigos ISO force: bool = False class UpdateRatesResponse(BaseModel): """Respuesta de actualización de tasas.""" status: str currencies_requested: int currencies_updated: int currencies_failed: int errors: List[str] = [] class TenantCurrencySettingsRequest(BaseModel): """Request para configuración de moneda del tenant.""" base_currency_id: str preferred_provider_code: str fallback_provider_code: Optional[str] = None update_interval: str = "daily" update_time: str = "06:00:00" update_timezone: str = "UTC" max_rate_change_percent: Decimal = Decimal("10.00") require_approval_above: Decimal = Decimal("20.00") class TenantCurrencySettingsResponse(BaseModel): """Respuesta de configuración de moneda.""" base_currency_id: str base_currency_code: str preferred_provider: str fallback_provider: Optional[str] update_interval: str update_time: str update_timezone: str next_scheduled_update: Optional[str] max_rate_change_percent: Decimal require_approval_above: Decimal # ===================================================== # ENDPOINTS # ===================================================== @router.get("/current", response_model=List[CurrencyRateResponse]) async def get_current_rates( currencies: Optional[str] = Query(None, description="Códigos ISO separados por coma"), rate_service: CurrencyRateService = Depends(), current_user = Depends(get_current_user) ): """ Obtiene las tasas de cambio vigentes para el tenant. - Sin parámetros: retorna todas las monedas activas - Con `currencies`: retorna solo las monedas especificadas """ tenant_id = current_user.tenant_id currency_list = currencies.split(",") if currencies else None rates = await rate_service.get_current_rates( tenant_id=tenant_id, currency_codes=currency_list ) return [CurrencyRateResponse(**r.dict()) for r in rates] @router.get("/{currency_id}", response_model=CurrencyRateResponse) async def get_rate( currency_id: str = Path(..., description="ID de la moneda"), date: Optional[date] = Query(None, description="Fecha para la tasa"), rate_service: CurrencyRateService = Depends(), current_user = Depends(get_current_user) ): """Obtiene la tasa de una moneda específica.""" try: rate = await rate_service.get_rate( tenant_id=current_user.tenant_id, currency_id=currency_id, target_date=date ) return CurrencyRateResponse(**rate.dict()) except Exception as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(e) ) @router.get("/{currency_id}/history", response_model=CurrencyRateHistoryResponse) async def get_rate_history( currency_id: str = Path(..., description="ID de la moneda"), date_from: date = Query(..., description="Fecha inicio"), date_to: date = Query(..., description="Fecha fin"), rate_service: CurrencyRateService = Depends(), current_user = Depends(get_current_user) ): """Obtiene el historial de tasas de una moneda.""" rates = await rate_service.get_rate_history( tenant_id=current_user.tenant_id, currency_id=currency_id, date_from=date_from, date_to=date_to ) return CurrencyRateHistoryResponse( currency_id=currency_id, currency_code=rates[0].currency_code if rates else "", rates=[CurrencyRateResponse(**r.dict()) for r in rates] ) @router.post("/convert", response_model=ConversionResponse) async def convert_currency( request: ConversionRequest, rate_service: CurrencyRateService = Depends(), current_user = Depends(get_current_user) ): """Convierte un monto entre dos monedas.""" rate = await rate_service.get_rate_between( tenant_id=current_user.tenant_id, from_currency_id=request.from_currency_id, to_currency_id=request.to_currency_id, target_date=request.date ) converted = await rate_service.convert_amount( tenant_id=current_user.tenant_id, amount=request.amount, from_currency_id=request.from_currency_id, to_currency_id=request.to_currency_id, target_date=request.date ) return ConversionResponse( original_amount=request.amount, from_currency=request.from_currency_id, converted_amount=converted, to_currency=request.to_currency_id, rate_used=rate, rate_date=request.date or date.today() ) @router.post("/", response_model=CurrencyRateResponse) @require_permissions(["currency_rates:write"]) async def set_rate_manual( request: SetRateRequest, rate_service: CurrencyRateService = Depends(), current_user = Depends(get_current_user) ): """ Establece una tasa de cambio manualmente. Requiere permiso `currency_rates:write`. """ rate = await rate_service.set_rate_manual( tenant_id=current_user.tenant_id, currency_id=request.currency_id, rate=request.rate, effective_date=request.effective_date, user_id=current_user.id, notes=request.notes ) return CurrencyRateResponse(**rate.dict()) @router.post("/update", response_model=UpdateRatesResponse) @require_permissions(["currency_rates:update"]) async def update_rates( request: UpdateRatesRequest, update_service: CurrencyRateUpdateService = Depends(), current_user = Depends(get_current_user) ): """ Dispara actualización de tasas desde proveedor externo. Requiere permiso `currency_rates:update`. """ result = await update_service.update_rates_for_tenant( tenant_id=current_user.tenant_id, currencies=request.currencies, force=request.force ) return UpdateRatesResponse( status=result.status, currencies_requested=result.currencies_requested, currencies_updated=result.currencies_updated, currencies_failed=result.currencies_failed, errors=result.errors ) # ===================================================== # CONFIGURACIÓN # ===================================================== @router.get("/settings", response_model=TenantCurrencySettingsResponse) async def get_settings( settings_service = Depends(), current_user = Depends(get_current_user) ): """Obtiene la configuración de monedas del tenant.""" settings = await settings_service.get_by_tenant(current_user.tenant_id) if not settings: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Currency settings not configured" ) return TenantCurrencySettingsResponse(**settings.dict()) @router.put("/settings", response_model=TenantCurrencySettingsResponse) @require_permissions(["currency_settings:write"]) async def update_settings( request: TenantCurrencySettingsRequest, settings_service = Depends(), current_user = Depends(get_current_user) ): """ Actualiza la configuración de monedas del tenant. Requiere permiso `currency_settings:write`. """ settings = await settings_service.upsert( tenant_id=current_user.tenant_id, **request.dict() ) return TenantCurrencySettingsResponse(**settings.dict()) # ===================================================== # PROVEEDORES # ===================================================== class ProviderInfo(BaseModel): code: str name: str base_currency: str supported_currencies: List[str] @router.get("/providers", response_model=List[ProviderInfo]) async def list_providers( provider_registry = Depends() ): """Lista los proveedores de tasas disponibles.""" providers = provider_registry.list_all() return [ ProviderInfo( code=p.code, name=p.name, base_currency=p.base_currency, supported_currencies=p.supported_currencies ) for p in providers ] ``` --- ## 4. Reglas de Negocio ### 4.1 Gestión de Tasas | Regla | Descripción | |-------|-------------| | RN-TCR-001 | Solo una tasa por moneda por día por tenant | | RN-TCR-002 | La moneda base del tenant siempre tiene tasa 1.0 | | RN-TCR-003 | Cross-rates se calculan automáticamente vía moneda base | | RN-TCR-004 | Tasas con más de X% de cambio generan alertas | | RN-TCR-005 | Cambios mayores a umbral requieren aprobación manual | | RN-TCR-006 | Tasas históricas son inmutables después de cierre de período | ### 4.2 Actualización Automática | Regla | Descripción | |-------|-------------| | RN-TCR-007 | Si falla proveedor principal, usar proveedor de respaldo | | RN-TCR-008 | Máximo 3 reintentos con backoff exponencial | | RN-TCR-009 | Si falla todo, mantener última tasa conocida y alertar | | RN-TCR-010 | No sobrescribir tasas manuales del mismo día | | RN-TCR-011 | Rate limiting según configuración del proveedor | ### 4.3 Conversión | Regla | Descripción | |-------|-------------| | RN-TCR-012 | Usar tasa del día de transacción, no del día actual | | RN-TCR-013 | Si no hay tasa del día exacto, usar la más reciente anterior | | RN-TCR-014 | Redondear según decimales de moneda destino | | RN-TCR-015 | Registrar tasa usada en cada transacción para auditoría | --- ## 5. Pruebas ### 5.1 Casos de Prueba Unitarios ```python # tests/unit/multi_currency/test_currency_rate_service.py import pytest from decimal import Decimal from datetime import date, timedelta from unittest.mock import AsyncMock, MagicMock from app.modules.multi_currency.domain.services.currency_rate_service import ( CurrencyRateService, RateSourceType ) class TestCurrencyRateService: """Tests para CurrencyRateService.""" @pytest.fixture def rate_repository(self): return AsyncMock() @pytest.fixture def provider_repository(self): return AsyncMock() @pytest.fixture def settings_repository(self): return AsyncMock() @pytest.fixture def alert_service(self): return AsyncMock() @pytest.fixture def cache_service(self): return AsyncMock() @pytest.fixture def service( self, rate_repository, provider_repository, settings_repository, alert_service, cache_service ): return CurrencyRateService( rate_repository=rate_repository, provider_repository=provider_repository, settings_repository=settings_repository, alert_service=alert_service, cache_service=cache_service ) async def test_get_rate_from_cache(self, service, cache_service): """Debe retornar tasa desde cache si existe.""" cache_service.get.return_value = "1.2345" rate = await service.get_rate( tenant_id="tenant-1", currency_id="currency-usd", target_date=date.today() ) assert rate == Decimal("1.2345") cache_service.get.assert_called_once() async def test_get_rate_from_database( self, service, cache_service, rate_repository ): """Debe obtener tasa de DB si no está en cache.""" cache_service.get.return_value = None mock_rate = MagicMock() mock_rate.rate = Decimal("1.5678") rate_repository.find_effective_rate.return_value = mock_rate rate = await service.get_rate( tenant_id="tenant-1", currency_id="currency-eur", target_date=date.today() ) assert rate == Decimal("1.5678") rate_repository.find_effective_rate.assert_called_once() cache_service.set.assert_called_once() async def test_get_rate_base_currency_returns_one( self, service, cache_service, rate_repository, settings_repository ): """Debe retornar 1.0 para moneda base.""" cache_service.get.return_value = None rate_repository.find_effective_rate.return_value = None mock_settings = MagicMock() mock_settings.base_currency_id = "currency-mxn" settings_repository.get_by_tenant.return_value = mock_settings rate = await service.get_rate( tenant_id="tenant-1", currency_id="currency-mxn", target_date=date.today() ) assert rate == Decimal("1.0") async def test_convert_amount(self, service): """Debe convertir monto correctamente.""" service.get_rate_between = AsyncMock(return_value=Decimal("20.5")) service._get_currency_config = AsyncMock(return_value=MagicMock(decimal_places=2)) converted = await service.convert_amount( tenant_id="tenant-1", amount=Decimal("100"), from_currency_id="currency-usd", to_currency_id="currency-mxn" ) assert converted == Decimal("2050.00") async def test_convert_same_currency(self, service): """Debe retornar mismo monto si monedas son iguales.""" converted = await service.convert_amount( tenant_id="tenant-1", amount=Decimal("100"), from_currency_id="currency-usd", to_currency_id="currency-usd" ) assert converted == Decimal("100") async def test_set_rate_manual_creates_alert_on_large_change( self, service, rate_repository, settings_repository, alert_service ): """Debe crear alerta si cambio es mayor al umbral.""" # Configuración con umbral de 5% mock_settings = MagicMock() mock_settings.base_currency_id = "currency-mxn" mock_settings.max_rate_change_percent = Decimal("5.0") mock_settings.require_approval_above = Decimal("10.0") settings_repository.get_by_tenant.return_value = mock_settings # Tasa actual: 20.0 mock_current = MagicMock() mock_current.rate = Decimal("20.0") rate_repository.find_effective_rate.return_value = mock_current # Nueva tasa: 22.0 (10% de cambio) rate_repository.upsert.return_value = MagicMock() await service.set_rate_manual( tenant_id="tenant-1", currency_id="currency-usd", rate=Decimal("22.0"), effective_date=date.today(), user_id="user-1" ) alert_service.create_alert.assert_called_once() class TestCrossRate: """Tests para cálculo de cross-rates.""" async def test_cross_rate_calculation(self): """Debe calcular cross-rate correctamente.""" # USD/MXN = 20.0, EUR/MXN = 22.0 # EUR/USD = 22.0 / 20.0 = 1.1 service = CurrencyRateService( rate_repository=AsyncMock(), provider_repository=AsyncMock(), settings_repository=AsyncMock(), alert_service=AsyncMock(), cache_service=AsyncMock() ) async def mock_get_rate(tenant_id, currency_id, target_date): rates = { "currency-usd": Decimal("20.0"), "currency-eur": Decimal("22.0"), "currency-mxn": Decimal("1.0") } return rates.get(currency_id, Decimal("1.0")) service.get_rate = mock_get_rate cross_rate = await service.get_rate_between( tenant_id="tenant-1", from_currency_id="currency-usd", to_currency_id="currency-eur", target_date=date.today() ) assert cross_rate == Decimal("1.1") ``` ### 5.2 Tests de Proveedores ```python # tests/unit/multi_currency/test_providers.py import pytest from decimal import Decimal from unittest.mock import AsyncMock, patch, MagicMock import httpx from app.modules.multi_currency.infrastructure.providers.ecb_provider import ( ECBProvider ) from app.modules.multi_currency.infrastructure.providers.banxico_provider import ( BanxicoProvider ) class TestECBProvider: """Tests para proveedor ECB.""" @pytest.fixture def provider(self): return ECBProvider() @pytest.fixture def sample_ecb_response(self): return b""" """ async def test_fetch_rates_success(self, provider, sample_ecb_response): """Debe parsear respuesta del ECB correctamente.""" mock_response = MagicMock() mock_response.content = sample_ecb_response mock_response.raise_for_status = MagicMock() with patch.object(httpx.AsyncClient, 'get', return_value=mock_response): rates = await provider.fetch_rates() assert "USD" in rates assert rates["USD"] == Decimal("1.0876") assert rates["EUR"] == Decimal("1.0") assert "MXN" in rates async def test_fetch_specific_currencies(self, provider, sample_ecb_response): """Debe filtrar monedas solicitadas.""" mock_response = MagicMock() mock_response.content = sample_ecb_response mock_response.raise_for_status = MagicMock() with patch.object(httpx.AsyncClient, 'get', return_value=mock_response): rates = await provider.fetch_rates(currencies=["USD", "GBP"]) assert "USD" in rates assert "GBP" in rates assert "JPY" not in rates assert "EUR" in rates # EUR siempre incluido class TestBanxicoProvider: """Tests para proveedor Banxico.""" @pytest.fixture def provider(self): return BanxicoProvider() @pytest.fixture def sample_banxico_response(self): return { "bmx": { "series": [ { "idSerie": "SF43718", "datos": [ {"fecha": "15/01/2024", "dato": "17.1234"} ] } ] } } async def test_fetch_rates_success(self, provider, sample_banxico_response): """Debe parsear respuesta de Banxico correctamente.""" mock_response = MagicMock() mock_response.json.return_value = sample_banxico_response mock_response.raise_for_status = MagicMock() with patch.object(httpx.AsyncClient, 'get', return_value=mock_response): rates = await provider.fetch_rates( credentials={"token": "test-token"} ) assert "USD" in rates assert rates["USD"] == Decimal("17.1234") assert rates["MXN"] == Decimal("1.0") async def test_fetch_rates_requires_token(self, provider): """Debe fallar sin token de API.""" with pytest.raises(ValueError, match="token required"): await provider.fetch_rates() ``` ### 5.3 Tests de Integración ```python # tests/integration/multi_currency/test_rate_update_flow.py import pytest from datetime import date from decimal import Decimal from app.modules.multi_currency.domain.services.currency_rate_service import ( CurrencyRateService, CurrencyRateUpdateService ) @pytest.mark.integration class TestRateUpdateFlow: """Tests de integración para flujo de actualización.""" async def test_full_update_flow( self, db_session, test_tenant, rate_service: CurrencyRateService, update_service: CurrencyRateUpdateService ): """Test del flujo completo de actualización de tasas.""" # 1. Configurar tenant con proveedor ECB await self._setup_tenant_settings( db_session, test_tenant.id, provider="ecb" ) # 2. Ejecutar actualización result = await update_service.update_rates_for_tenant( tenant_id=test_tenant.id, currencies=["USD", "GBP", "JPY"] ) # 3. Verificar resultado assert result.status in ["success", "partial_success"] assert result.currencies_updated > 0 # 4. Verificar tasas guardadas usd_rate = await rate_service.get_rate( tenant_id=test_tenant.id, currency_id="currency-usd", target_date=date.today() ) assert usd_rate > Decimal("0") async def test_conversion_uses_correct_rate( self, db_session, test_tenant, rate_service: CurrencyRateService ): """Test que conversión usa tasa correcta.""" # Setup: crear tasas conocidas await rate_service.set_rate_manual( tenant_id=test_tenant.id, currency_id="currency-usd", rate=Decimal("17.50"), effective_date=date.today(), user_id="test-user" ) # Convertir 100 USD a MXN converted = await rate_service.convert_amount( tenant_id=test_tenant.id, amount=Decimal("100"), from_currency_id="currency-usd", to_currency_id="currency-mxn" # MXN es base ) # MXN base = 1.0, USD = 17.50 # 100 USD * (1.0 / 17.50) = ~5.71 MXN (invertido) # O: 100 USD en términos de MXN = 100 * 17.50 = 1750 MXN assert converted == Decimal("1750.00") ``` --- ## 6. Configuración y Despliegue ### 6.1 Variables de Entorno ```bash # .env.example # Configuración general de tasas CURRENCY_RATE_CACHE_TTL=86400 CURRENCY_RATE_UPDATE_BATCH_SIZE=10 CURRENCY_RATE_MAX_RETRIES=3 CURRENCY_RATE_RETRY_DELAY_SECONDS=60 # Proveedor ECB ECB_API_URL=https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml ECB_TIMEOUT_SECONDS=30 # Proveedor Banxico (requiere token por tenant) BANXICO_API_URL=https://www.banxico.org.mx/SieAPIRest/service/v1/series # Proveedor BCB BCB_API_URL=https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata # Alertas RATE_CHANGE_ALERT_THRESHOLD_PERCENT=10 RATE_CHANGE_APPROVAL_THRESHOLD_PERCENT=20 # Scheduler RATE_UPDATE_CHECK_INTERVAL_MINUTES=5 RATE_LOG_RETENTION_DAYS=365 ``` ### 6.2 Migraciones ```sql -- migrations/xxx_create_currency_rates.sql -- Extensión para parent_path si no existe CREATE EXTENSION IF NOT EXISTS ltree; -- Ejecutar DDL del modelo de datos (sección 3.1) -- ... -- Índices adicionales para rendimiento CREATE INDEX CONCURRENTLY idx_currency_rates_tenant_date ON currency_rates(tenant_id, effective_date DESC); CREATE INDEX CONCURRENTLY idx_rate_alerts_pending ON currency_rate_alerts(tenant_id, status) WHERE status = 'pending'; -- Datos iniciales de proveedores INSERT INTO currency_rate_providers (code, name, provider_type, base_currency_id, api_url, response_format) SELECT 'ecb', 'European Central Bank', 'ecb', c.id, 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml', 'xml' FROM currencies c WHERE c.iso_code = 'EUR'; ``` --- ## 7. Métricas y Monitoreo ### 7.1 Métricas Clave | Métrica | Tipo | Descripción | |---------|------|-------------| | `currency_rate_updates_total` | Counter | Total de actualizaciones ejecutadas | | `currency_rate_updates_success` | Counter | Actualizaciones exitosas | | `currency_rate_updates_failed` | Counter | Actualizaciones fallidas | | `currency_rate_update_duration_ms` | Histogram | Duración de actualización | | `currency_rate_conversions_total` | Counter | Total de conversiones realizadas | | `currency_rate_cache_hits` | Counter | Cache hits de tasas | | `currency_rate_cache_misses` | Counter | Cache misses de tasas | | `currency_rate_alerts_total` | Counter | Total de alertas generadas | ### 7.2 Alertas de Monitoreo ```yaml # alerts/currency_rates.yaml groups: - name: currency_rates rules: - alert: CurrencyRateUpdateFailed expr: increase(currency_rate_updates_failed[1h]) > 3 for: 5m labels: severity: warning annotations: summary: "Currency rate updates failing" description: "More than 3 rate update failures in the last hour" - alert: CurrencyRateStale expr: currency_rate_age_hours > 48 for: 1h labels: severity: critical annotations: summary: "Currency rates are stale" description: "Currency rates haven't been updated in 48+ hours" - alert: CurrencyRateProviderDown expr: currency_rate_provider_consecutive_failures > 5 for: 10m labels: severity: warning annotations: summary: "Currency rate provider failing" description: "Provider {{ $labels.provider }} has failed 5+ consecutive times" ``` --- ## 8. Dependencias ### 8.1 Dependencias de Sistema | Componente | Versión | Propósito | |------------|---------|-----------| | PostgreSQL | 15+ | Base de datos | | Redis | 7+ | Cache de tasas | | APScheduler | 3.10+ | Programación de jobs | ### 8.2 Dependencias de Módulos | Módulo | Tipo | Descripción | |--------|------|-------------| | `base` | Requerido | Currencies, tenants | | `accounting` | Opcional | Integración con asientos | | `notifications` | Opcional | Alertas de cambios | --- ## 9. Anexos ### 9.1 Proveedores Disponibles | Proveedor | Código | Moneda Base | Autenticación | Límite | |-----------|--------|-------------|---------------|--------| | BCE | `ecb` | EUR | No | Ilimitado | | Banxico | `banxico` | MXN | Token | 1000/día | | BCB | `bcb` | BRL | No | Ilimitado | | BCRP | `bcrp` | PEN | No | Ilimitado | | BOC | `boc` | CAD | No | Ilimitado | | Fixer.io | `fixer` | EUR | API Key | Según plan | | Open Exchange | `openexchange` | USD | App ID | Según plan | ### 9.2 Códigos de Error | Código | Descripción | |--------|-------------| | `RATE_NOT_FOUND` | No se encontró tasa para fecha/moneda | | `INVALID_RATE` | Tasa con valor inválido (0 o negativo) | | `CANNOT_SET_BASE_RATE` | Intento de modificar tasa de moneda base | | `PROVIDER_NOT_FOUND` | Proveedor no configurado | | `PROVIDER_ERROR` | Error de comunicación con proveedor | | `RATE_LIMIT_EXCEEDED` | Límite de API excedido | | `SETTINGS_NOT_FOUND` | Configuración de tenant no existe | --- ## 10. Referencias - [IAS 21 - Effects of Changes in Foreign Exchange Rates](https://www.ifrs.org/issued-standards/list-of-standards/ias-21-the-effects-of-changes-in-foreign-exchange-rates/) - [ECB Exchange Rates API](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html) - [Banxico SIE API](https://www.banxico.org.mx/SieAPIRest/service/v1/doc/catalogoSeries) - [ISO 4217 Currency Codes](https://www.iso.org/iso-4217-currency-codes.html) - [Odoo currency_rate_live](https://github.com/odoo/odoo/tree/17.0/addons/currency_rate_live)