Introducción
Como desarrollador de Django, ¿alguna vez te has enfrentado al desafío de implementar un sistema contable y te has sentido abrumado por la terminología financiera? No estás solo. La contabilidad puede parecer un mundo completamente diferente a la programación, pero en realidad, comparten muchos conceptos fundamentales.
En este tutorial, aprenderemos a implementar los libros contables principales (Diario, Mayor y Balance de Comprobación) utilizando Django Admin, relacionando conceptos contables con paradigmas de programación que ya conoces. Por ejemplo, un asiento contable es similar a una transacción en base de datos: ambos deben mantener la integridad y ser atómicos.
Al final de este tutorial, serás capaz de implementar un sistema contable robusto utilizando únicamente Django Admin, con validaciones automáticas y pruebas unitarias que garanticen la integridad de los datos.
Prerrequisitos
- Python 3.12+
- Django 5.0+
Conceptos Clave
La Contabilidad desde una Perspectiva de Desarrollador
-
Libro Diario → Piense en ello como su historial de confirmaciones de Git
- Cada asiento es como un commit que registra un cambio en el estado financiero
- Debe ser inmutable (como un commit)
- Tiene timestamp y metadata (autor, descripción)
-
Libro Mayor → Similar a un agregador de logs por categoría
- Agrupa transacciones por "cuenta" (como agrupar logs por servicio)
- Mantiene saldos actualizados (como un contador en Redis)
-
Balance de Comprobación → Equivalente a revision de cambios
- Verifica que el sistema esté en un estado consistente
- Debe cumplir reglas matemáticas específicas (como assertions en tests)
Implementación Práctica
Modelos Django
from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import Sum
from decimal import Decimal
import uuid
class Account(models.Model):
"""
Representa una cuenta contable.
Similar a una tabla en base de datos donde se agregan o restan valores.
"""
ACCOUNT_TYPES = [
('ASSET', 'Activo'),
('LIABILITY', 'Pasivo'),
('EQUITY', 'Patrimonio'),
('INCOME', 'Ingreso'),
('EXPENSE', 'Gasto'),
]
code = models.CharField(max_length=20, unique=True)
name = models.CharField(max_length=100)
type = models.CharField(max_length=10, choices=ACCOUNT_TYPES)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.code} - {self.name}"
def get_balance(self, end_date=None):
"""
Calcula el saldo de la cuenta hasta una fecha específica.
Similar a un query con filtro temporal.
"""
entries = JournalEntryLine.objects.filter(account=self)
if end_date:
entries = entries.filter(entry__date__lte=end_date)
debit_sum = entries.aggregate(Sum('debit'))['debit__sum'] or Decimal('0')
credit_sum = entries.aggregate(Sum('credit'))['credit__sum'] or Decimal('0')
if self.type in ['ASSET', 'EXPENSE']:
return debit_sum - credit_sum
return credit_sum - debit_sum
class Meta:
ordering = ['code']
class JournalEntry(models.Model):
"""
Representa un asiento contable.
Similar a una transacción en base de datos.
"""
reference = models.UUIDField(default=uuid.uuid4, editable=False)
date = models.DateField()
description = models.TextField()
is_posted = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey('auth.User', on_delete=models.PROTECT)
def clean(self):
"""
Validaciones a nivel de asiento.
Similar a validaciones de integridad en DB.
"""
if self.is_posted:
raise ValidationError("No se puede modificar un asiento contabilizado")
# Verificar balance entre débito y crédito
total_debit = sum(line.debit for line in self.lines.all())
total_credit = sum(line.credit for line in self.lines.all())
if total_debit != total_credit:
raise ValidationError("El asiento no está balanceado")
def post(self):
"""
Contabiliza el asiento.
Similar a un commit en una transacción.
"""
self.clean()
self.is_posted = True
self.save()
class Meta:
verbose_name_plural = "Journal Entries"
ordering = ['-date', '-created_at']
class JournalEntryLine(models.Model):
"""
Representa una línea de asiento contable.
Similar a un detalle de transacción.
"""
entry = models.ForeignKey(
JournalEntry,
related_name='lines',
on_delete=models.CASCADE
)
account = models.ForeignKey(
Account,
on_delete=models.PROTECT
)
description = models.CharField(max_length=200)
debit = models.DecimalField(
max_digits=15,
decimal_places=2,
default=0
)
credit = models.DecimalField(
max_digits=15,
decimal_places=2,
default=0
)
def clean(self):
"""
Validaciones a nivel de línea.
Similar a check constraints en DB.
"""
if self.debit < 0 or self.credit < 0:
raise ValidationError("Los montos no pueden ser negativos")
if self.debit > 0 and self.credit > 0:
raise ValidationError("Una línea no puede tener débito y crédito")
if self.debit == 0 and self.credit == 0:
raise ValidationError("El monto debe ser mayor que cero")
class Meta:
ordering = ['id']
class TrialBalance(models.Model):
"""
Balance de Comprobación.
Similar a un reporte de health check del sistema.
"""
date = models.DateField(unique=True)
is_closed = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey('auth.User', on_delete=models.PROTECT)
def generate(self):
"""
Genera el balance de comprobación.
Similar a generar un reporte de estado del sistema.
"""
if self.is_closed:
raise ValidationError("Este balance ya está cerrado")
# Eliminar líneas existentes
self.lines.all().delete()
# Generar nuevas líneas
for account in Account.objects.filter(is_active=True):
balance = account.get_balance(self.date)
if balance != 0:
TrialBalanceLine.objects.create(
trial_balance=self,
account=account,
debit=max(balance, 0),
credit=max(-balance, 0)
)
def validate(self):
"""
Valida el balance de comprobación.
Similar a ejecutar test suite.
"""
total_debit = self.lines.aggregate(Sum('debit'))['debit__sum'] or 0
total_credit = self.lines.aggregate(Sum('credit'))['credit__sum'] or 0
if total_debit != total_credit:
raise ValidationError(
f"El balance no cuadra: Débito={total_debit}, Crédito={total_credit}"
)
class Meta:
ordering = ['-date']
class TrialBalanceLine(models.Model):
"""
Línea de Balance de Comprobación.
Similar a un resultado individual de test.
"""
trial_balance = models.ForeignKey(
TrialBalance,
related_name='lines',
on_delete=models.CASCADE
)
account = models.ForeignKey(Account, on_delete=models.PROTECT)
debit = models.DecimalField(max_digits=15, decimal_places=2, default=0)
credit = models.DecimalField(max_digits=15, decimal_places=2, default=0)
class Meta:
ordering = ['account__code']
Configuración del Admin
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from .models import Account, JournalEntry, JournalEntryLine, TrialBalance, TrialBalanceLine
@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
list_display = ['code', 'name', 'type', 'get_balance_display', 'is_active']
list_filter = ['type', 'is_active']
search_fields = ['code', 'name']
def get_balance_display(self, obj):
balance = obj.get_balance()
return f"{balance:,.2f}"
get_balance_display.short_description = 'Balance'
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', 'credit']
return []
@admin.register(JournalEntry)
class JournalEntryAdmin(admin.ModelAdmin):
list_display = ['reference', 'date', 'description', 'is_posted', 'created_by']
list_filter = ['is_posted', 'date']
search_fields = ['reference', 'description']
readonly_fields = ['reference', 'created_by']
inlines = [JournalEntryLineInline]
def save_model(self, request, obj, form, change):
if not obj.pk:
obj.created_by = request.user
super().save_model(request, obj, form, change)
def get_readonly_fields(self, request, obj=None):
if obj and obj.is_posted:
return ['reference', 'date', 'description', 'is_posted', 'created_by']
return ['reference', 'created_by']
class TrialBalanceLineInline(admin.TabularInline):
model = TrialBalanceLine
extra = 0
readonly_fields = ['account', 'debit', 'credit']
can_delete = False
def has_add_permission(self, request, obj=None):
return False
@admin.register(TrialBalance)
class TrialBalanceAdmin(admin.ModelAdmin):
list_display = ['date', 'is_closed', 'created_by', 'created_at']
readonly_fields = ['created_by', 'created_at']
inlines = [TrialBalanceLineInline]
def save_model(self, request, obj, form, change):
if not obj.pk:
obj.created_by = request.user
super().save_model(request, obj, form, change)
def response_add(self, request, obj):
obj.generate()
obj.validate()
return super().response_add(request, obj)
Tests Unitarios
from django.test import TestCase
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from decimal import Decimal
from .models import Account, JournalEntry, JournalEntryLine, TrialBalance
from datetime import date
class AccountingTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
password='testpass'
)
# Crear cuentas de prueba
self.cash = Account.objects.create(
code='1000',
name='Cash',
type='ASSET'
)
self.bank = Account.objects.create(
code='1001',
name='Bank',
type='ASSET'
)
self.revenue = Account.objects.create(
code='4000',
name='Revenue',
type='INCOME'
)
def test_journal_entry_balance(self):
"""Verifica que los asientos contables estén balanceados"""
entry = JournalEntry.objects.create(
date=date.today(),
description='Test Entry',
created_by=self.user
)
# Crear líneas desbalanceadas
JournalEntryLine.objects.create(
entry=entry,
account=self.cash,
description='Debit line',
debit=Decimal('100.00')
)
JournalEntryLine.objects.create(
entry=entry,
account=self.revenue,
description='Credit line',
credit=Decimal('90.00')
)
# Intentar contabilizar debería fallar
with self.assertRaises(ValidationError):
entry.post()
def test_trial_balance(self):
"""Verifica la generación del balance de comprobación"""
# Crear y contabilizar un asiento
entry = JournalEntry.objects.create(
date=date.today(),
description='Test Entry',
created_by=self.user
)
JournalEntryLine.objects.create(
entry=entry,
account=self.cash,
description='Debit line',
debit=Decimal('100.00')
)
JournalEntryLine.objects.create(
entry=entry,
account=self.revenue,
description='Credit line',
credit=Decimal('100.00')
)
entry.post()
# Generar balance de comprobación
trial_balance = TrialBalance.objects.create(
date=date.today(),
created_by=self.user
)
trial_balance.generate()
# Verificar saldos
self.assertEqual(
trial_balance.lines.aggregate(Sum('debit'))['debit__sum'],
trial_balance.lines.aggregate(Sum('credit'))['credit__sum']
)
Ejemplo Real: Sistema de Transferencias entre Cuentas
Crear una Transferencia Bancaria
def create_bank_transfer(
date,
amount,
from_account,
to_account,
description,
user
):
"""
Crea un asiento contable para una transferencia bancaria.
"""
entry = JournalEntry.objects.create(
date=date,
description=description,
created_by=user
)
# Crear línea de débito (cuenta destino)
JournalEntryLine.objects.create(
entry=entry,
account=to_account,
description=f"Transferencia recibida de {from_account.name}",
debit=amount
)
# Crear línea de crédito (cuenta origen)
JournalEntryLine.objects.create(
entry=entry,
account=from_account,
description=f"Transferencia enviada a {to_account.name}",
credit=amount
)
# Contabilizar el asiento
entry.post()
return entry
# Ejemplo de uso:
from decimal import Decimal
from datetime import date
transfer = create_bank_transfer(
date=date.today(),
amount=Decimal('1000.00'),
from_account=Account.objects.get(code='1001'), # Cuenta Banco
to_account=Account.objects.get(code='1000'), # Cuenta Caja
description="Transferencia para gastos operativos",
user=request.user
)
Mejores Prácticas
1. Validaciones de Seguridad
from django.contrib.admin import ModelAdmin
from django.core.exceptions import PermissionDenied
class JournalEntryAdmin(ModelAdmin):
def has_delete_permission(self, request, obj=None):
# Prevenir eliminación de asientos contabilizados
if obj and obj.is_posted:
return False
return super().has_delete_permission(request, obj)
def save_model(self, request, obj, form, change):
# Verificar permisos especiales para contabilizar
if 'is_posted' in form.changed_data:
if not request.user.has_perm('accounting.post_journal_entry'):
raise PermissionDenied("No tienes permiso para contabilizar asientos")
super().save_model(request, obj, form, change)
2. Manejo de Errores
class AccountingError(Exception):
"""Base exception for accounting errors"""
pass
class UnbalancedEntryError(AccountingError):
"""Raised when a journal entry is not balanced"""
pass
class PostedEntryError(AccountingError):
"""Raised when trying to modify a posted entry"""
pass
class JournalEntry(models.Model):
# ... otros campos ...
def post(self):
try:
# Validar balance
total_debit = self.lines.aggregate(Sum('debit'))['debit__sum'] or 0
total_credit = self.lines.aggregate(Sum('credit'))['credit__sum'] or 0
if total_debit != total_credit:
raise UnbalancedEntryError(
f"Débito ({total_debit}) != Crédito ({total_credit})"
)
# Validar que no esté contabilizado
if self.is_posted:
raise PostedEntryError("El asiento ya está contabilizado")
# Contabilizar
self.is_posted = True
self.save()
except AccountingError as e:
# Log del error
logger.error(f"Error al contabilizar asiento {self.reference}: {str(e)}")
raise
3. Patrones de Diseño Recomendados
Command Pattern para Operaciones Contables
from abc import ABC, abstractmethod
from decimal import Decimal
from typing import List
class AccountingCommand(ABC):
@abstractmethod
def execute(self) -> JournalEntry:
pass
class TransferCommand(AccountingCommand):
def __init__(
self,
date: date,
amount: Decimal,
from_account: Account,
to_account: Account,
description: str,
user: User
):
self.date = date
self.amount = amount
self.from_account = from_account
self.to_account = to_account
self.description = description
self.user = user
def execute(self) -> JournalEntry:
return create_bank_transfer(
self.date,
self.amount,
self.from_account,
self.to_account,
self.description,
self.user
)
# Uso del Command Pattern
transfer_cmd = TransferCommand(
date=date.today(),
amount=Decimal('1000.00'),
from_account=bank_account,
to_account=cash_account,
description="Transferencia operativa",
user=current_user
)
journal_entry = transfer_cmd.execute()
Queries Útiles
1. Balance por Tipo de Cuenta
from django.db.models import Sum, Case, When, F
def get_account_type_balances(end_date=None):
"""
Obtiene los saldos agrupados por tipo de cuenta
"""
query = Account.objects.all()
if end_date:
lines = JournalEntryLine.objects.filter(
entry__date__lte=end_date,
entry__is_posted=True
)
else:
lines = JournalEntryLine.objects.filter(entry__is_posted=True)
balances = query.annotate(
total_debit=Sum(
Case(
When(
journalentryline__in=lines,
then='journalentryline__debit'
),
default=0
)
),
total_credit=Sum(
Case(
When(
journalentryline__in=lines,
then='journalentryline__credit'
),
default=0
)
),
balance=Case(
When(
type__in=['ASSET', 'EXPENSE'],
then=F('total_debit') - F('total_credit')
),
default=F('total_credit') - F('total_debit')
)
).values('type').annotate(
total_balance=Sum('balance')
)
return balances
2. Libro Mayor
def get_ledger(account, start_date=None, end_date=None):
"""
Obtiene el libro mayor para una cuenta específica
"""
lines = JournalEntryLine.objects.filter(
account=account,
entry__is_posted=True
).select_related('entry')
if start_date:
lines = lines.filter(entry__date__gte=start_date)
if end_date:
lines = lines.filter(entry__date__lte=end_date)
return lines.order_by('entry__date', 'entry__id')
Conclusión
En este tutorial, hemos construido un sistema contable robusto utilizando Django Admin, aplicando principios de programación familiares para entender conceptos contables. Los puntos clave son:
- La contabilidad es similar a un sistema de logging transaccional
- Las validaciones son cruciales para mantener la integridad de los datos
- El patrón Command nos ayuda a encapsular operaciones contables complejas
Top comments (0)