Contabilidad para Django Developers: Implementando Reglas de Negocio Contables
Enrique Lazo Bello
Posted on November 9, 2024
Introducción
¿Alguna vez te has preguntado por qué los contadores insisten tanto en que "el debe igual al haber"? Como desarrollador de Django, probablemente estés más familiarizado con transacciones ACID que con asientos contables. Pero aquí está la sorpresa: los principios contables son muy similares a los conceptos de programación que ya conoces.
En este tutorial, transformaremos conceptos contables en código Django limpio y mantenible. Aprenderás a implementar las reglas de negocio básicas de contabilidad utilizando solo el admin de Django, sin necesidad de crear vistas personalizadas.
Al final de este tutorial, serás capaz de:
- Implementar validaciones contables robustas
- Garantizar la integridad de los datos financieros
- Crear un sistema contable básico pero profesional
Prerrequisitos
# Crear un nuevo ambiente virtual
python -m venv env
source env/bin/activate # En Windows: env\Scripts\activate
# Instalar dependencias
pip install django==5.0
pip install python-dateutil
Conceptos Clave
1. Asientos Contables = Transacciones Atómicas
Así como una transacción en base de datos debe ser atómica, un asiento contable debe cuadrar (debe = haber). No existe un estado intermedio válido.
2. Período Contable = Ventana de Tiempo Válida
Similar a un rate limiter o una ventana de caché, un período contable define cuándo se pueden realizar operaciones.
3. Plan de Cuentas = Enum Jerárquico
Las cuentas contables son como un enum jerárquico con estados (activa/inactiva) y metadatos.
Implementación Práctica
Primero, definamos nuestros modelos:
# accounting/models.py
from django.db import models
from django.core.exceptions import ValidationError
from django.utils import timezone
from dateutil.relativedelta import relativedelta
import decimal
class AccountingPeriod(models.Model):
name = models.CharField(max_length=100)
start_date = models.DateField()
end_date = models.DateField()
is_closed = models.BooleanField(default=False)
class Meta:
ordering = ['-start_date']
def __str__(self):
return f"{self.name} ({self.start_date} - {self.end_date})"
def clean(self):
if self.start_date > self.end_date:
raise ValidationError("La fecha de inicio debe ser anterior a la fecha final")
# Verificar solapamiento de períodos
overlapping = AccountingPeriod.objects.filter(
start_date__lte=self.end_date,
end_date__gte=self.start_date
).exclude(pk=self.pk)
if overlapping.exists():
raise ValidationError("El período se solapa con otros existentes")
def is_date_valid(self, date):
return self.start_date <= date <= self.end_date and not self.is_closed
class Currency(models.Model):
code = models.CharField(max_length=3, unique=True)
name = models.CharField(max_length=100)
symbol = models.CharField(max_length=5)
is_active = models.BooleanField(default=True)
def __str__(self):
return f"{self.code} - {self.name}"
class Meta:
verbose_name_plural = "Currencies"
class ExchangeRate(models.Model):
currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
date = models.DateField()
rate = models.DecimalField(max_digits=10, decimal_places=4)
class Meta:
unique_together = ['currency', 'date']
def __str__(self):
return f"{self.currency.code} - {self.date} - {self.rate}"
def clean(self):
if self.rate <= 0:
raise ValidationError("El tipo de cambio debe ser mayor a 0")
class Account(models.Model):
ACCOUNT_TYPES = [
('A', 'Asset'),
('L', 'Liability'),
('E', 'Equity'),
('I', 'Income'),
('X', 'Expense'),
]
code = models.CharField(max_length=20, unique=True)
name = models.CharField(max_length=200)
type = models.CharField(max_length=1, choices=ACCOUNT_TYPES)
is_active = models.BooleanField(default=True)
allow_movements = models.BooleanField(default=True)
parent = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.PROTECT,
related_name='children'
)
def __str__(self):
return f"{self.code} - {self.name}"
def clean(self):
if self.parent:
if self.parent.type != self.type:
raise ValidationError("La cuenta padre debe ser del mismo tipo")
if not self.code.startswith(self.parent.code):
raise ValidationError("El código debe empezar con el código de la cuenta padre")
class JournalEntry(models.Model):
date = models.DateField()
number = models.CharField(max_length=20, unique=True)
description = models.TextField()
currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
is_posted = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.number} - {self.date}"
def clean(self):
if not self.pk: # Solo para nuevos registros
# Generar número automático
last_entry = JournalEntry.objects.order_by('-number').first()
if last_entry:
last_num = int(last_entry.number)
self.number = str(last_num + 1).zfill(8)
else:
self.number = "00000001"
# Verificar período contable
period = AccountingPeriod.objects.filter(
start_date__lte=self.date,
end_date__gte=self.date,
is_closed=False
).first()
if not period:
raise ValidationError("La fecha no corresponde a un período contable válido y abierto")
# Verificar tipo de cambio
if not ExchangeRate.objects.filter(
currency=self.currency,
date=self.date
).exists():
raise ValidationError(
f"No existe tipo de cambio para {self.currency.code} en la fecha {self.date}"
)
def save(self, *args, **kwargs):
if not self.is_posted:
self.verify_balance()
super().save(*args, **kwargs)
def verify_balance(self):
"""Verifica que el asiento cuadre (debe = haber)"""
if self.pk: # Solo para entradas existentes
debit_sum = self.lines.aggregate(
total=models.Sum('debit_amount')
)['total'] or 0
credit_sum = self.lines.aggregate(
total=models.Sum('credit_amount')
)['total'] or 0
if debit_sum != credit_sum:
raise ValidationError(
f"El asiento no cuadra. Debe: {debit_sum}, Haber: {credit_sum}"
)
class JournalEntryLine(models.Model):
entry = models.ForeignKey(
JournalEntry,
on_delete=models.CASCADE,
related_name='lines'
)
account = models.ForeignKey(Account, on_delete=models.PROTECT)
description = models.CharField(max_length=200)
debit_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=0
)
credit_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=0
)
def clean(self):
if not self.account.is_active:
raise ValidationError("La cuenta no está activa")
if not self.account.allow_movements:
raise ValidationError("La cuenta no permite movimientos")
if self.debit_amount < 0 or self.credit_amount < 0:
raise ValidationError("Los montos no pueden ser negativos")
if (self.debit_amount > 0 and self.credit_amount > 0):
raise ValidationError("Una línea no puede tener debe y haber")
if (self.debit_amount == 0 and self.credit_amount == 0):
raise ValidationError("El monto debe ser mayor a 0")
Ahora, configuremos el admin de Django para manejar estos modelos:
# accounting/admin.py
from django.contrib import admin
from django.db.models import Sum
from .models import (
AccountingPeriod,
Currency,
ExchangeRate,
Account,
JournalEntry,
JournalEntryLine
)
@admin.register(AccountingPeriod)
class AccountingPeriodAdmin(admin.ModelAdmin):
list_display = ['name', 'start_date', 'end_date', 'is_closed']
search_fields = ['name']
list_filter = ['is_closed']
@admin.register(Currency)
class CurrencyAdmin(admin.ModelAdmin):
list_display = ['code', 'name', 'symbol', 'is_active']
search_fields = ['code', 'name']
list_filter = ['is_active']
@admin.register(ExchangeRate)
class ExchangeRateAdmin(admin.ModelAdmin):
list_display = ['currency', 'date', 'rate']
search_fields = ['currency__code']
list_filter = ['currency', 'date']
@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
list_display = [
'code',
'name',
'type',
'is_active',
'allow_movements',
'parent'
]
search_fields = ['code', 'name']
list_filter = ['type', 'is_active', 'allow_movements']
ordering = ['code']
class JournalEntryLineInline(admin.TabularInline):
model = JournalEntryLine
extra = 2
def get_readonly_fields(self, request, obj=None):
if obj and obj.is_posted:
return ['account', 'description', 'debit_amount', 'credit_amount']
return []
@admin.register(JournalEntry)
class JournalEntryAdmin(admin.ModelAdmin):
list_display = [
'number',
'date',
'description',
'currency',
'is_posted',
'total_amount'
]
search_fields = ['number', 'description']
list_filter = ['date', 'currency', 'is_posted']
inlines = [JournalEntryLineInline]
readonly_fields = ['number']
def get_readonly_fields(self, request, obj=None):
if obj and obj.is_posted:
return ['date', 'number', 'description', 'currency', 'is_posted']
return ['number']
def total_amount(self, obj):
if obj.pk:
return obj.lines.aggregate(
total=Sum('debit_amount')
)['total'] or 0
return 0
def save_model(self, request, obj, form, change):
if not change: # Nuevo registro
obj.save()
else:
if 'is_posted' in form.changed_data and obj.is_posted:
# Verificar balance antes de marcar como contabilizado
obj.verify_balance()
obj.save()
Ejemplo Real: Sistema de Transferencias entre Cuentas
Veamos cómo implementar una transferencia entre cuentas bancarias:
# accounting/tests.py
from django.test import TestCase
from django.core.exceptions import ValidationError
from decimal import Decimal
from datetime import date
from .models import (
AccountingPeriod,
Currency,
ExchangeRate,
Account,
JournalEntry,
JournalEntryLine
)
class AccountingTests(TestCase):
def setUp(self):
# Crear período contable
self.period = AccountingPeriod.objects.create(
name="2024-01",
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31)
)
# Crear moneda y tipo de cambio
self.currency = Currency.objects.create(
code="USD",
name="US Dollar",
symbol="$"
)
self.exchange_rate = ExchangeRate.objects.create(
currency=self.currency,
date=date(2024, 1, 15),
rate=Decimal('1.0')
)
# Crear cuentas bancarias
self.bank_parent = Account.objects.create(
code="1001",
name="Bancos",
type='A'
)
self.bank_account_1 = Account.objects.create(
code="1001001",
name="Banco 1",
type='A',
parent=self.bank_parent
)
self.bank_account_2 = Account.objects.create(
code="1001002",
name="Banco 2",
type='A',
parent=self.bank_parent
)
def test_bank_transfer(self):
# Crear asiento de transferencia
entry = JournalEntry.objects.create(
date=date(2024, 1, 15),
description="Transferencia entre bancos",
currency=self.currency
)
# Débito al banco 2
JournalEntryLine.objects.create(
entry=entry,
account=self.bank_account_2,
description="Transferencia recibida",
debit_amount=Decimal('1000.00')
)
# Crédito al banco 1
JournalEntryLine.objects.create(
entry=entry,
account=self.bank_account_1,
description="Transferencia enviada",
credit_amount=Decimal('1000.00')
)
# Verificar balance
entry.verify_balance()
# Marcar como contabilizado
entry.is_posted = True
entry.save()
def test_invalid_transfer(self):
# Probar transferencia con montos desbalanceados
entry = JournalEntry.objects.create(
date=date(2024, 1, 15),
description="Transferencia inválida",
currency=self.currency
)
JournalEntryLine.objects.create(
entry=entry,
account=self.bank_account_2,
description="Transferencia recibida",
debit_amount=Decimal('1000.00')
)
JournalEntryLine.objects.create(
entry=entry,
account=self.bank_account_1,
description="Transferencia enviada",
credit_amount=Decimal('900.00') # Monto diferente
)
# Debería levantar una excepción al verificar el balance
with self.assertRaises(ValidationError):
entry.verify_balance()
def test_closed_period(self):
# Cerrar el período
self.period.is_closed = True
self.period.save()
# Intentar crear un asiento en período cerrado
with self.assertRaises(ValidationError):
JournalEntry.objects.create(
date=date(2024, 1, 15),
description="Asiento en período cerrado",
currency=self.currency
)
def test_inactive_account(self):
# Desactivar cuenta
self.bank_account_1.is_active = False
self.bank_account_1.save()
entry = JournalEntry.objects.create(
date=date(2024, 1, 15),
description="Transferencia con cuenta inactiva",
currency=self.currency
)
# Intentar crear línea con cuenta inactiva
with self.assertRaises(ValidationError):
JournalEntryLine.objects.create(
entry=entry,
account=self.bank_account_1,
description="Débito a cuenta inactiva",
debit_amount=Decimal('1000.00')
)
Mejores Prácticas
1. Validaciones de Seguridad
Para garantizar la integridad de los datos contables, hemos implementado múltiples niveles de validación:
# accounting/models.py
class JournalEntry(models.Model):
# ... (código anterior) ...
def save(self, *args, **kwargs):
if self.is_posted:
raise ValidationError(
"No se pueden modificar asientos contabilizados"
)
super().save(*args, **kwargs)
class JournalEntryLine(models.Model):
# ... (código anterior) ...
def save(self, *args, **kwargs):
if self.entry.is_posted:
raise ValidationError(
"No se pueden modificar líneas de asientos contabilizados"
)
super().save(*args, **kwargs)
2. Manejo de Errores
Implementamos un middleware personalizado para manejar errores contables de manera amigable:
# accounting/middleware.py
from django.core.exceptions import ValidationError
from django.contrib import messages
from django.shortcuts import redirect
class AccountingErrorMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
return response
def process_exception(self, request, exception):
if isinstance(exception, ValidationError):
messages.error(
request,
f"Error de validación contable: {str(exception)}"
)
return redirect('admin:index')
return None
3. Patrones de Diseño Recomendados
- Unit of Work: Implementado a través de las transacciones de Django
- Repository Pattern: Usando el ORM de Django
- Value Objects: Para manejo de montos y tipos de cambio
# accounting/utils.py
from decimal import Decimal
from dataclasses import dataclass
from typing import Optional
@dataclass
class Money:
amount: Decimal
currency: str
def __post_init__(self):
self.amount = Decimal(str(self.amount))
def __add__(self, other):
if not isinstance(other, Money):
raise TypeError("Solo se pueden sumar objetos Money")
if self.currency != other.currency:
raise ValueError("No se pueden sumar montos en diferentes monedas")
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other):
if not isinstance(other, Money):
raise TypeError("Solo se pueden restar objetos Money")
if self.currency != other.currency:
raise ValueError("No se pueden restar montos en diferentes monedas")
return Money(self.amount - other.amount, self.currency)
@dataclass
class ExchangeRateValue:
from_currency: str
to_currency: str
rate: Decimal
date: date
def convert(self, money: Money) -> Money:
if money.currency != self.from_currency:
raise ValueError("Moneda incompatible")
return Money(
money.amount * self.rate,
self.to_currency
)
Consultas Útiles
Aquí hay algunos ejemplos de consultas comunes:
# Obtener balance por cuenta
from django.db.models import Sum, F, Value
from django.db.models.functions import Coalesce
def get_account_balance(account_code, date_from, date_to):
return JournalEntryLine.objects.filter(
account__code__startswith=account_code,
entry__date__range=[date_from, date_to],
entry__is_posted=True
).aggregate(
balance=Coalesce(
Sum(F('debit_amount') - F('credit_amount')),
Value(0)
)
)['balance']
# Libro mayor
def get_ledger(account_code, date_from, date_to):
return JournalEntryLine.objects.filter(
account__code__startswith=account_code,
entry__date__range=[date_from, date_to],
entry__is_posted=True
).select_related(
'entry', 'account'
).order_by('entry__date', 'entry__number')
# Balance de comprobación
def get_trial_balance(date_from, date_to):
return Account.objects.filter(
lines__entry__date__range=[date_from, date_to],
lines__entry__is_posted=True
).annotate(
debit_sum=Coalesce(
Sum('lines__debit_amount'),
Value(0)
),
credit_sum=Coalesce(
Sum('lines__credit_amount'),
Value(0)
),
balance=F('debit_sum') - F('credit_sum')
).filter(balance__ne=0)
Conclusión
Hemos construido un sistema contable básico pero robusto utilizando Django. Los puntos clave a recordar son:
- Las reglas contables se implementan como validaciones en los modelos
- El admin de Django proporciona una interfaz lista para usar
- Los tests son cruciales para garantizar la integridad contable
Posted on November 9, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.