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
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
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']
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'
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')
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
Mejores Prácticas
-
Validaciones de Seguridad
- Usar
decimal.Decimal
para todos los cálculos monetarios - Implementar permisos granulares en el admin
- Validar cuadre contable antes de guardar
- Usar
-
Manejo de Errores
- Usar transacciones para asegurar atomicidad
- Validar tipos de cambio antes de operaciones
- Implementar logging detallado
-
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)