DEV Community

Enrique Lazo Bello
Enrique Lazo Bello

Posted on

Contabilidad para Django Developers: Implementando Reportería Regulatoria SBS

Introducción

Como desarrollador Django, ¿alguna vez te has enfrentado al desafío de implementar un sistema contable regulatorio? Si la palabra "contabilidad" te genera la misma ansiedad que un deployment fallido en producción, este tutorial es para ti.

Aprenderás a implementar un sistema de reportería regulatoria básica para la SBS (Superintendencia de Banca y Seguros) utilizando Django, traduciendo conceptos contables a términos de programación que ya conoces. Al final, podrás:

  • Crear modelos Django para manejar estados financieros
  • Implementar validaciones automáticas de cuadre contable
  • Gestionar operaciones en múltiples monedas
  • Todo esto usando únicamente el admin de Django

Prerrequisitos

Django>=5.0
python-decimal>=3.12
Enter fullscreen mode Exit fullscreen mode

Conocimientos necesarios:

  • Experiencia básica con Django y su ORM
  • Familiaridad con modelos Django y el admin
  • Conocimientos básicos de Python 3

Conceptos Clave para Developers

La Contabilidad como Sistema de Base de Datos

Piensa en la contabilidad como un sistema de base de datos con estas características:

  • ACID compliant (como PostgreSQL)
  • Double-entry (cada transacción afecta dos o más registros)
  • Event sourcing (cada movimiento queda registrado)
# Analogía entre conceptos
class ContabilidadConceptos:
    """
    Conceptos contables traducidos a términos de programación:

    Cuenta Contable    -> Tabla
    Asiento           -> Transacción
    Debe/Haber        -> Crédito/Débito (como git add/remove)
    Balance           -> Checkpoint/Snapshot
    """
    pass
Enter fullscreen mode Exit fullscreen mode

Implementación Práctica

1. Modelos Base

from decimal import Decimal
from django.db import models
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

class Currency(models.Model):
    code = models.CharField(max_length=3, unique=True)
    name = models.CharField(max_length=50)
    exchange_rate = models.DecimalField(
        max_digits=10, 
        decimal_places=4,
        help_text="Tipo de cambio respecto a moneda local"
    )

    class Meta:
        verbose_name_plural = "Currencies"

    def __str__(self):
        return self.code

class Account(models.Model):
    """Cuenta contable según plan contable SBS"""
    ACCOUNT_TYPES = [
        ('A', 'Activo'),
        ('P', 'Pasivo'),
        ('R', 'Resultado'),
    ]

    code = models.CharField(max_length=6, unique=True)
    name = models.CharField(max_length=100)
    type = models.CharField(max_length=1, choices=ACCOUNT_TYPES)
    currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
    is_analytic = models.BooleanField(
        default=False,
        help_text="Indica si la cuenta acepta movimientos"
    )

    def __str__(self):
        return f"{self.code} - {self.name}"

    def get_balance(self, date=None):
        """Obtiene el saldo de la cuenta a una fecha"""
        query = self.movements.all()
        if date:
            query = query.filter(accounting_date__lte=date)

        debit = query.aggregate(
            total=models.Sum('debit_amount', default=Decimal('0.00'))
        )['total']
        credit = query.aggregate(
            total=models.Sum('credit_amount', default=Decimal('0.00'))
        )['total']

        if self.type in ['A', 'R']:
            return debit - credit
        return credit - debit

    class Meta:
        ordering = ['code']

class AccountingEntry(models.Model):
    """Asiento contable"""
    date = models.DateField()
    number = models.CharField(max_length=10, unique=True)
    description = models.TextField()
    is_adjusted = models.BooleanField(
        default=False,
        help_text="Indica si es un asiento de ajuste"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def clean(self):
        """Validación de cuadre contable"""
        if self.pk:  # Solo validamos entradas existentes
            debit_sum = self.movements.aggregate(
                total=models.Sum('debit_amount', default=Decimal('0.00'))
            )['total']
            credit_sum = self.movements.aggregate(
                total=models.Sum('credit_amount', default=Decimal('0.00'))
            )['total']

            if debit_sum != credit_sum:
                raise ValidationError(
                    _('El asiento no cuadra. Débito: %(debit)s, Crédito: %(credit)s'),
                    params={'debit': debit_sum, 'credit': credit_sum},
                )

    class Meta:
        verbose_name_plural = "Accounting Entries"

class AccountingMove(models.Model):
    """Movimiento contable individual"""
    entry = models.ForeignKey(
        AccountingEntry,
        on_delete=models.PROTECT,
        related_name='movements'
    )
    account = models.ForeignKey(
        Account,
        on_delete=models.PROTECT,
        related_name='movements'
    )
    debit_amount = models.DecimalField(
        max_digits=15,
        decimal_places=2,
        default=Decimal('0.00')
    )
    credit_amount = models.DecimalField(
        max_digits=15,
        decimal_places=2,
        default=Decimal('0.00')
    )
    exchange_rate = models.DecimalField(
        max_digits=10,
        decimal_places=4,
        help_text="Tipo de cambio usado en la operación"
    )
    accounting_date = models.DateField()

    def clean(self):
        """Validaciones de movimiento"""
        if self.debit_amount and self.credit_amount:
            raise ValidationError(
                _('Un movimiento no puede tener débito y crédito simultáneamente')
            )

        if not self.debit_amount and not self.credit_amount:
            raise ValidationError(
                _('El movimiento debe tener al menos un monto')
            )

        if not self.account.is_analytic:
            raise ValidationError(
                _('La cuenta seleccionada no acepta movimientos')
            )

    def save(self, *args, **kwargs):
        if not self.exchange_rate:
            self.exchange_rate = self.account.currency.exchange_rate
        super().save(*args, **kwargs)

    class Meta:
        ordering = ['accounting_date', 'id']
Enter fullscreen mode Exit fullscreen mode

2. Admin personalizado

from django.contrib import admin
from django.db.models import Sum, Value
from django.db.models.functions import Coalesce

@admin.register(Currency)
class CurrencyAdmin(admin.ModelAdmin):
    list_display = ['code', 'name', 'exchange_rate']
    search_fields = ['code', 'name']

@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
    list_display = ['code', 'name', 'type', 'currency', 'current_balance']
    list_filter = ['type', 'currency', 'is_analytic']
    search_fields = ['code', 'name']

    def current_balance(self, obj):
        return obj.get_balance()
    current_balance.short_description = 'Saldo Actual'

class AccountingMoveInline(admin.TabularInline):
    model = AccountingMove
    extra = 2
    fields = ['account', 'debit_amount', 'credit_amount', 'exchange_rate', 'accounting_date']

@admin.register(AccountingEntry)
class AccountingEntryAdmin(admin.ModelAdmin):
    list_display = ['number', 'date', 'description', 'is_adjusted', 'total_amount']
    list_filter = ['date', 'is_adjusted']
    search_fields = ['number', 'description']
    inlines = [AccountingMoveInline]

    def total_amount(self, obj):
        return obj.movements.aggregate(
            total=Coalesce(Sum('debit_amount'), Value(0))
        )['total']
    total_amount.short_description = 'Monto Total'
Enter fullscreen mode Exit fullscreen mode

3. Tests Unitarios

import pytest
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.utils import timezone
from .models import Currency, Account, AccountingEntry, AccountingMove

@pytest.mark.django_db
class TestAccountingSystem:
    def setup_method(self):
        # Configuración inicial
        self.pen = Currency.objects.create(
            code='PEN',
            name='Soles',
            exchange_rate=Decimal('1.0000')
        )
        self.usd = Currency.objects.create(
            code='USD',
            name='Dólares',
            exchange_rate=Decimal('3.7500')
        )

        # Cuentas de prueba
        self.cash_pen = Account.objects.create(
            code='101101',
            name='Caja MN',
            type='A',
            currency=self.pen,
            is_analytic=True
        )
        self.cash_usd = Account.objects.create(
            code='101102',
            name='Caja ME',
            type='A',
            currency=self.usd,
            is_analytic=True
        )
        self.income = Account.objects.create(
            code='701101',
            name='Ingresos Financieros',
            type='R',
            currency=self.pen,
            is_analytic=True
        )

    def test_account_balance(self):
        # Crear asiento contable
        entry = AccountingEntry.objects.create(
            date=timezone.now().date(),
            number='AS0001',
            description='Test Entry'
        )

        # Movimientos
        AccountingMove.objects.create(
            entry=entry,
            account=self.cash_pen,
            debit_amount=Decimal('1000.00'),
            accounting_date=entry.date
        )
        AccountingMove.objects.create(
            entry=entry,
            account=self.income,
            credit_amount=Decimal('1000.00'),
            accounting_date=entry.date
        )

        assert self.cash_pen.get_balance() == Decimal('1000.00')
        assert self.income.get_balance() == Decimal('-1000.00')

    def test_unbalanced_entry(self):
        entry = AccountingEntry.objects.create(
            date=timezone.now().date(),
            number='AS0002',
            description='Unbalanced Entry'
        )

        AccountingMove.objects.create(
            entry=entry,
            account=self.cash_pen,
            debit_amount=Decimal('1000.00'),
            accounting_date=entry.date
        )

        with pytest.raises(ValidationError):
            entry.clean()

    def test_foreign_currency_movement(self):
        entry = AccountingEntry.objects.create(
            date=timezone.now().date(),
            number='AS0003',
            description='USD Transaction'
        )

        # Movimiento en USD
        AccountingMove.objects.create(
            entry=entry,
            account=self.cash_usd,
            debit_amount=Decimal('100.00'),
            exchange_rate=Decimal('3.7500'),
            accounting_date=entry.date
        )

        # Contrapartida en PEN
        AccountingMove.objects.create(
            entry=entry,
            account=self.income,
            credit_amount=Decimal('375.00'),
            exchange_rate=Decimal('1.0000'),
            accounting_date=entry.date
        )

        assert self.cash_usd.get_balance() == Decimal('100.00')
        assert self.income.get_balance() == Decimal('-375.00')
Enter fullscreen mode Exit fullscreen mode

Ejemplo Real: Sistema de Transferencias

def transfer_between_accounts(
    from_account: Account,
    to_account: Account,
    amount: Decimal,
    date: date,
    description: str
) -> AccountingEntry:
    """
    Realiza una transferencia entre cuentas, manejando diferentes monedas
    """
    # Validaciones previas
    if not from_account.is_analytic or not to_account.is_analytic:
        raise ValidationError('Las cuentas deben ser analíticas')

    if amount <= 0:
        raise ValidationError('El monto debe ser positivo')

    # Crear asiento
    entry = AccountingEntry.objects.create(
        date=date,
        number=f'TR{timezone.now().strftime("%Y%m%d%H%M%S")}',
        description=description
    )

    # Si las monedas son diferentes, calculamos la conversión
    if from_account.currency != to_account.currency:
        to_amount = amount * (
            to_account.currency.exchange_rate / from_account.currency.exchange_rate
        )
    else:
        to_amount = amount

    # Crear movimientos
    AccountingMove.objects.create(
        entry=entry,
        account=from_account,
        credit_amount=amount,
        exchange_rate=from_account.currency.exchange_rate,
        accounting_date=date
    )

    AccountingMove.objects.create(
        entry=entry,
        account=to_account,
        debit_amount=to_amount,
        exchange_rate=to_account.currency.exchange_rate,
        accounting_date=date
    )

    return entry
Enter fullscreen mode Exit fullscreen mode

Mejores Prácticas

  1. Validaciones de Seguridad

    • Usar decimal.Decimal para todos los cálculos monetarios
    • Implementar permisos granulares en el admin
    • Validar cuadre contable antes de guardar
  2. Manejo de Errores

    • Usar transacciones para asegurar atomicidad
    • Validar tipos de cambio antes de operaciones
    • Implementar logging detallado
  3. Patrones de Diseño

    • Repository pattern para consultas complejas
    • Factory pattern para creación de asientos
    • Observer pattern para auditoría

Conclusión

Este tutorial te ha proporcionado una base sólida para implementar reportería regulatoria en Django. Los conceptos clave aprendidos son:

  • Modelado de datos contables en Django
  • Validaciones automáticas de cuadre
  • Manejo de múltiples monedas
  • Testing de operaciones contables

Top comments (0)