Contabilidad para Django Developers: Control de Cambios y Tipo de Cambio

Enrique Lazo Bello - Nov 9 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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()
            )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Mejores Prácticas

  1. Validaciones de Seguridad
    • Usar decimal.Decimal para todos los cálculos monetarios

Mejores Prácticas (continuación)

  1. 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
# 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)
Enter fullscreen mode Exit fullscreen mode
  1. 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()
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode

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'))
Enter fullscreen mode Exit fullscreen mode

Conclusión

Has aprendido a implementar un sistema robusto de control de cambios en Django que:

  1. Maneja tipos de cambio y diferencias cambiarias
  2. Implementa validaciones y seguridad
  3. Proporciona reportes de posición de cambio
  4. Es fácilmente extensible
. . . . . . . . . . .