Introducción
Como desarrollador Django, ¿alguna vez te has preguntado cómo manejar correctamente las operaciones en diferentes monedas en tus aplicaciones? Si estás construyendo sistemas empresariales o financieros, entender el control de cambios no es solo una necesidad contable - es un requisito técnico crucial.
En este tutorial, transformaremos conceptos contables complejos en modelos Django intuitivos, usando el admin de Django como nuestra interfaz principal. Aprenderás a manejar tipos de cambio, calcular diferencias cambiarias y mantener una posición de cambio precisa, todo sin escribir una sola vista personalizada.
Al finalizar, tendrás un sistema robusto de control de cambios que cualquier contador podrá usar desde el admin de Django, con validaciones automáticas y tests que aseguran su correcto funcionamiento.
Prerrequisitos
- Python 3.12+
- Django 5.0+
- Conocimientos básicos de modelos Django y el admin
- SQLite o PostgreSQL
- Git para control de versiones
Conceptos Clave
El Tipo de Cambio como un State Pattern
Piensa en el tipo de cambio como un state pattern en programación: representa el estado de una moneda en relación a otra en un momento específico. Así como guardamos timestamps para auditoría, necesitamos mantener un historial de tipos de cambio para operaciones financieras.
# Analogía en código
class Currency(str, Enum):
PEN = "PEN" # Soles
USD = "USD" # Dólares
EUR = "EUR" # Euros
# El tipo de cambio es como un estado inmutable
@dataclass(frozen=True)
class ExchangeRate:
currency_from: Currency
currency_to: Currency
rate: Decimal
timestamp: datetime
Diferencia de Cambio como Debugging
La diferencia de cambio es similar a encontrar un bug entre el comportamiento esperado y el actual. Cuando tienes una cuenta por pagar de $1000 registrada cuando el dólar estaba a S/3.80, pero la pagas cuando está a S/3.85, esa diferencia necesita ser registrada - como cuando haces un diff entre versiones de código.
Implementación Práctica
Modelos Base
from django.db import models
from django.core.validators import MinValueValidator
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.utils import timezone
class Currency(models.Model):
code = models.CharField(max_length=3, unique=True)
name = models.CharField(max_length=50)
is_base = models.BooleanField(default=False)
def save(self, *args, **kwargs):
if self.is_base:
# Solo puede haber una moneda base
Currency.objects.filter(is_base=True).update(is_base=False)
super().save(*args, **kwargs)
def __str__(self):
return f"{self.code} - {self.name}"
class Meta:
verbose_name_plural = "Currencies"
class ExchangeRate(models.Model):
currency_from = models.ForeignKey(
Currency,
on_delete=models.PROTECT,
related_name='rates_from'
)
currency_to = models.ForeignKey(
Currency,
on_delete=models.PROTECT,
related_name='rates_to'
)
rate = models.DecimalField(
max_digits=10,
decimal_places=4,
validators=[MinValueValidator(Decimal('0.0001'))]
)
date = models.DateField(default=timezone.now)
is_sbs = models.BooleanField(
default=False,
verbose_name="Is SBS Rate",
help_text="Indica si es tipo de cambio oficial SBS"
)
class Meta:
unique_together = [['currency_from', 'currency_to', 'date']]
def clean(self):
if self.currency_from == self.currency_to:
raise ValidationError(
"La moneda origen y destino no pueden ser iguales"
)
if self.is_sbs and ExchangeRate.objects.filter(
date=self.date,
is_sbs=True,
currency_from=self.currency_from,
currency_to=self.currency_to
).exclude(id=self.id).exists():
raise ValidationError(
"Ya existe un tipo de cambio SBS para esta fecha"
)
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
# Crear tipo de cambio inverso automáticamente
if not ExchangeRate.objects.filter(
currency_from=self.currency_to,
currency_to=self.currency_from,
date=self.date
).exists():
ExchangeRate.objects.create(
currency_from=self.currency_to,
currency_to=self.currency_from,
rate=Decimal('1.0') / self.rate,
date=self.date,
is_sbs=self.is_sbs
)
class ForexAccount(models.Model):
currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
code = models.CharField(max_length=10, unique=True)
name = models.CharField(max_length=100)
balance = models.DecimalField(
max_digits=15,
decimal_places=2,
default=Decimal('0.00')
)
def __str__(self):
return f"{self.code} - {self.name} ({self.currency.code})"
def get_balance_base_currency(self, date=None):
if not date:
date = timezone.now().date()
base_currency = Currency.objects.get(is_base=True)
if self.currency == base_currency:
return self.balance
try:
rate = ExchangeRate.objects.get(
currency_from=self.currency,
currency_to=base_currency,
date=date
)
return self.balance * rate.rate
except ExchangeRate.DoesNotExist:
raise ValidationError(
f"No existe tipo de cambio para {date}"
)
class ForexTransaction(models.Model):
TRANSACTION_TYPES = [
('IN', 'Ingreso'),
('OUT', 'Egreso'),
]
account = models.ForeignKey(
ForexAccount,
on_delete=models.PROTECT,
related_name='transactions'
)
type = models.CharField(max_length=3, choices=TRANSACTION_TYPES)
amount = models.DecimalField(max_digits=15, decimal_places=2)
exchange_rate = models.ForeignKey(
ExchangeRate,
on_delete=models.PROTECT,
null=True,
blank=True
)
date = models.DateField(default=timezone.now)
description = models.TextField()
def clean(self):
if self.exchange_rate and (
self.exchange_rate.currency_from != self.account.currency
):
raise ValidationError(
"El tipo de cambio debe corresponder a la moneda de la cuenta"
)
def save(self, *args, **kwargs):
self.full_clean()
if not self.exchange_rate:
base_currency = Currency.objects.get(is_base=True)
self.exchange_rate = ExchangeRate.objects.get(
currency_from=self.account.currency,
currency_to=base_currency,
date=self.date
)
# Actualizar saldo de la cuenta
multiplier = Decimal('1.0') if self.type == 'IN' else Decimal('-1.0')
self.account.balance += (self.amount * multiplier)
self.account.save()
super().save(*args, **kwargs)
Configuración del Admin
from django.contrib import admin
from django.utils.html import format_html
@admin.register(Currency)
class CurrencyAdmin(admin.ModelAdmin):
list_display = ['code', 'name', 'is_base']
search_fields = ['code', 'name']
list_filter = ['is_base']
@admin.register(ExchangeRate)
class ExchangeRateAdmin(admin.ModelAdmin):
list_display = [
'date',
'currency_from',
'currency_to',
'rate',
'is_sbs'
]
list_filter = ['date', 'currency_from', 'currency_to', 'is_sbs']
search_fields = ['currency_from__code', 'currency_to__code']
date_hierarchy = 'date'
@admin.register(ForexAccount)
class ForexAccountAdmin(admin.ModelAdmin):
list_display = [
'code',
'name',
'currency',
'balance',
'base_currency_balance'
]
search_fields = ['code', 'name']
list_filter = ['currency']
def base_currency_balance(self, obj):
try:
base_balance = obj.get_balance_base_currency()
base_currency = Currency.objects.get(is_base=True)
return f"{base_currency.code} {base_balance:.2f}"
except Exception as e:
return format_html(
'<span style="color: red;">{}</span>',
str(e)
)
base_currency_balance.short_description = "Balance en Moneda Base"
@admin.register(ForexTransaction)
class ForexTransactionAdmin(admin.ModelAdmin):
list_display = [
'date',
'account',
'type',
'amount',
'exchange_rate',
'amount_base_currency'
]
list_filter = ['date', 'type', 'account__currency']
search_fields = ['description', 'account__name']
date_hierarchy = 'date'
def amount_base_currency(self, obj):
base_amount = obj.amount * obj.exchange_rate.rate
base_currency = Currency.objects.get(is_base=True)
return f"{base_currency.code} {base_amount:.2f}"
amount_base_currency.short_description = "Monto en Moneda Base"
Tests Unitarios
from django.test import TestCase
from django.core.exceptions import ValidationError
from decimal import Decimal
from django.utils import timezone
from .models import Currency, ExchangeRate, ForexAccount, ForexTransaction
class ForexTests(TestCase):
def setUp(self):
# Crear monedas base
self.pen = Currency.objects.create(
code='PEN',
name='Soles',
is_base=True
)
self.usd = Currency.objects.create(
code='USD',
name='Dólares'
)
# Crear tipo de cambio
self.exchange_rate = ExchangeRate.objects.create(
currency_from=self.usd,
currency_to=self.pen,
rate=Decimal('3.85'),
date=timezone.now().date(),
is_sbs=True
)
# Crear cuenta
self.account = ForexAccount.objects.create(
currency=self.usd,
code='1041',
name='Cuenta Corriente USD'
)
def test_exchange_rate_inverse(self):
"""Verifica que se cree automáticamente el tipo de cambio inverso"""
inverse_rate = ExchangeRate.objects.get(
currency_from=self.pen,
currency_to=self.usd,
date=self.exchange_rate.date
)
self.assertAlmostEqual(
float(inverse_rate.rate),
float(Decimal('1.0') / self.exchange_rate.rate),
places=4
)
def test_account_balance_update(self):
"""Verifica la actualización correcta del saldo"""
initial_balance = self.account.balance
# Crear transacción de ingreso
transaction = ForexTransaction.objects.create(
account=self.account,
type='IN',
amount=Decimal('1000.00'),
exchange_rate=self.exchange_rate,
description='Test transaction'
)
self.account.refresh_from_db()
self.assertEqual(
self.account.balance,
initial_balance + Decimal('1000.00')
)
# Verificar balance en moneda base
base_balance = self.account.get_balance_base_currency()
self.assertEqual(
base_balance,
Decimal('1000.00') * Decimal('3.85')
)
def test_invalid_exchange_rate(self):
"""Verifica que no se pueden crear tipos de cambio inválidos"""
with self.assertRaises(ValidationError):
ExchangeRate.objects.create(
currency_from=self.usd,
currency_to=self.usd,
rate=Decimal('1.00'),
date=timezone.now().date()
)
Ejemplo Real: Sistema de Transferencias
Caso de Uso: Transferencia entre Cuentas
from django.db import transaction
from decimal import Decimal
def transfer_between_accounts(
from_account: ForexAccount,
to_account: ForexAccount,
amount: Decimal,
date=None
) -> tuple[ForexTransaction, ForexTransaction]:
"""
Realiza una transferencia entre cuentas, manejando la conversión
de monedas si es necesario.
"""
if not date:
date = timezone.now().date()
with transaction.atomic():
# Obtener tipo de cambio si las monedas son diferentes
exchange_rate = None
if from_account.currency != to_account.currency:
exchange_rate = ExchangeRate.objects.get(
currency_from=from_account.currency,
currency_to=to_account.currency,
date=date
)
# Crear transacción de salida
outgoing = ForexTransaction.objects.create(
account=from_account,
type='OUT',
amount=amount,
date=date,
description=f'Transferencia a {to_account.code}'
)
# Calcular monto en moneda destino si es necesario
incoming_amount = amount
if exchange_rate:
incoming_amount = amount * exchange_rate.rate
# Crear transacción de ingreso
incoming = ForexTransaction.objects.create(
account=to_account,
type='IN',
amount=incoming_amount,
date=date,
description=f'Transferencia desde {from_account.code}'
)
return outgoing, incoming
Mejores Prácticas
-
Validaciones de Seguridad
- Usar
decimal.Decimal
para todos los cálculos monetarios
- Usar
Mejores Prácticas (continuación)
-
Validaciones de Seguridad
- Usar
decimal.Decimal
para todos los cálculos monetarios - Implementar validaciones a nivel de modelo
- Utilizar transacciones atómicas para operaciones críticas
- Mantener un registro de auditoría
- Usar
# models.py
class AuditMixin(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
related_name='%(class)s_created',
null=True
)
updated_by = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
related_name='%(class)s_updated',
null=True
)
class Meta:
abstract = True
class ForexTransaction(AuditMixin, models.Model):
# ... (código anterior del modelo) ...
def save(self, *args, **kwargs):
if not self.pk: # Solo en creación
if not hasattr(self, 'created_by') and hasattr(self, '_current_user'):
self.created_by = self._current_user
if hasattr(self, '_current_user'):
self.updated_by = self._current_user
super().save(*args, **kwargs)
-
Manejo de Errores
- Implementar excepciones personalizadas
- Logging detallado
- Mensajes de error amigables en el admin
# exceptions.py
class ForexError(Exception):
"""Base exception for forex operations"""
pass
class ExchangeRateError(ForexError):
"""Raised when there's an issue with exchange rates"""
pass
class InsufficientFundsError(ForexError):
"""Raised when an account has insufficient funds"""
pass
# models.py
from django.core.exceptions import ValidationError
import logging
logger = logging.getLogger(__name__)
class ForexAccount(AuditMixin, models.Model):
def withdraw(self, amount: Decimal) -> None:
if self.balance < amount:
logger.error(
f"Insufficient funds in account {self.code}: "
f"balance={self.balance}, amount={amount}"
)
raise InsufficientFundsError(
f"Saldo insuficiente. Disponible: {self.balance}"
)
self.balance -= amount
self.save()
-
Patrones de Diseño Recomendados
- Repository Pattern para acceso a datos
- Service Layer para lógica de negocio
- Command Pattern para operaciones complejas
# services.py
class ForexService:
@staticmethod
def calculate_exchange_difference(
account: ForexAccount,
date_from: date,
date_to: date
) -> Decimal:
"""
Calcula la diferencia de cambio para una cuenta en un período
"""
initial_rate = ExchangeRate.objects.get(
currency_from=account.currency,
currency_to=Currency.objects.get(is_base=True),
date=date_from
)
final_rate = ExchangeRate.objects.get(
currency_from=account.currency,
currency_to=Currency.objects.get(is_base=True),
date=date_to
)
balance = account.balance
initial_value = balance * initial_rate.rate
final_value = balance * final_rate.rate
return final_value - initial_value
class ForexReportService:
@staticmethod
def get_position_report(date: date) -> dict:
"""
Genera reporte de posición de cambio
"""
base_currency = Currency.objects.get(is_base=True)
accounts = ForexAccount.objects.all()
position = {
'date': date,
'positions': [],
'total_base_currency': Decimal('0.00')
}
for account in accounts:
if account.currency != base_currency:
balance_base = account.get_balance_base_currency(date)
position['positions'].append({
'account': account.code,
'currency': account.currency.code,
'balance': account.balance,
'balance_base': balance_base
})
position['total_base_currency'] += balance_base
return position
Tests Adicionales
# tests.py
class ForexServiceTests(TestCase):
def setUp(self):
# ... (setup anterior) ...
def test_exchange_difference_calculation(self):
"""
Verifica el cálculo correcto de la diferencia de cambio
"""
# Crear tipos de cambio para diferentes fechas
date1 = timezone.now().date()
date2 = date1 + timedelta(days=1)
ExchangeRate.objects.create(
currency_from=self.usd,
currency_to=self.pen,
rate=Decimal('3.85'),
date=date1
)
ExchangeRate.objects.create(
currency_from=self.usd,
currency_to=self.pen,
rate=Decimal('3.90'),
date=date2
)
# Establecer saldo en la cuenta
self.account.balance = Decimal('1000.00')
self.account.save()
# Calcular diferencia de cambio
difference = ForexService.calculate_exchange_difference(
self.account,
date1,
date2
)
# La diferencia debería ser (3.90 - 3.85) * 1000 = 50
self.assertEqual(difference, Decimal('50.00'))
def test_position_report(self):
"""
Verifica la generación correcta del reporte de posición
"""
date = timezone.now().date()
# Crear algunas cuentas con saldos
ForexAccount.objects.create(
currency=self.usd,
code='1042',
name='Otra cuenta USD',
balance=Decimal('2000.00')
)
position = ForexReportService.get_position_report(date)
self.assertEqual(len(position['positions']), 2)
self.assertGreater(position['total_base_currency'], Decimal('0'))
Conclusión
Has aprendido a implementar un sistema robusto de control de cambios en Django que:
- Maneja tipos de cambio y diferencias cambiarias
- Implementa validaciones y seguridad
- Proporciona reportes de posición de cambio
- Es fácilmente extensible