Introducción
¿Has intentado implementar un sistema contable y te has encontrado con términos como "debe", "haber" y "partida doble" que parecen escritos en otro idioma? Como desarrollador, probablemente estés familiarizado con las transacciones ACID en bases de datos, donde todo debe mantenerse consistente. La partida doble en contabilidad funciona de manera similar: cada operación debe mantener el sistema en equilibrio.
En este tutorial, aprenderás a implementar un sistema de partida doble en Django, traduciendo conceptos contables a términos de programación que ya conoces. Al final, podrás crear un sistema contable robusto que garantice la integridad de los datos financieros.
Prerrequisitos
- Python 3.12
- Django 5.0
- Conocimientos básicos de modelos en Django
- Un editor de código (VS Code, PyCharm, etc.)
Conceptos Clave: Contabilidad para Developers
La Partida Doble Explicada con Git y Bases de Datos
Analogía #1: Git Commits
# En Git:
# Un commit siempre involucra dos operaciones equilibradas:
git add file.py # (+100 bytes)
git rm old.py # (-100 bytes)
# El repositorio mantiene su balance
# En Contabilidad:
# Un asiento siempre involucra dos o más cuentas que se equilibran:
Banco += 1000 # Débito
Capital -= 1000 # Crédito
# Las cuentas mantienen su balance
Analogía #2: Transacciones SQL
-- En una transacción bancaria:
BEGIN TRANSACTION;
UPDATE cuenta_origen SET balance = balance - 100;
UPDATE cuenta_destino SET balance = balance + 100;
COMMIT;
-- En contabilidad es similar:
BEGIN TRANSACTION;
INSERT INTO asiento_contable (
cuenta='Banco', tipo='DEBITO', monto=100);
INSERT INTO asiento_contable (
cuenta='Capital', tipo='CREDITO', monto=100);
COMMIT;
Implementación en Django
1. Modelos Base
from django.db import models
from django.core.exceptions import ValidationError
from django.db import transaction
from decimal import Decimal
class Account(models.Model):
"""
Representa una cuenta contable (ej: Banco, Caja, Capital)
"""
ACCOUNT_TYPES = [
('ASSET', 'Activo'), # Aumenta con débito
('LIABILITY', 'Pasivo'), # Aumenta con crédito
('EQUITY', 'Capital'), # Aumenta con crédito
('INCOME', 'Ingreso'), # Aumenta con crédito
('EXPENSE', 'Gasto') # Aumenta con débito
]
code = models.CharField(
max_length=20,
unique=True,
help_text="Código único de la cuenta"
)
name = models.CharField(max_length=100)
type = models.CharField(
max_length=10,
choices=ACCOUNT_TYPES
)
balance = models.DecimalField(
max_digits=15,
decimal_places=2,
default=0,
editable=False
)
@property
def increases_with_debit(self):
"""Determina si la cuenta aumenta con débito"""
return self.type in ['ASSET', 'EXPENSE']
def update_balance(self, debit=0, credit=0):
"""Actualiza el balance según el tipo de cuenta"""
net_change = debit - credit
if not self.increases_with_debit:
net_change = -net_change
self.balance += Decimal(str(net_change))
self.save()
def __str__(self):
return f"{self.code} - {self.name}"
class Meta:
verbose_name = "Cuenta"
verbose_name_plural = "Cuentas"
class JournalEntry(models.Model):
"""
Representa un asiento contable (conjunto de movimientos balanceados)
"""
date = models.DateField()
reference = models.CharField(max_length=50)
description = models.TextField()
is_posted = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def clean(self):
"""Valida que el asiento esté balanceado"""
if self.pk: # Solo validar si ya existe
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 total de débitos debe igual al total de créditos. '
f'Débitos: {total_debit}, Créditos: {total_credit}'
)
@transaction.atomic
def post(self):
"""Aplica el asiento actualizando los balances"""
if self.is_posted:
raise ValidationError('Este asiento ya fue aplicado')
self.clean() # Validar balance
for line in self.lines.all():
line.account.update_balance(
debit=line.debit,
credit=line.credit
)
self.is_posted = True
self.save()
def __str__(self):
return f"{self.date} - {self.reference}"
class JournalEntryLine(models.Model):
"""
Representa una línea individual de un asiento contable
"""
entry = models.ForeignKey(
JournalEntry,
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
)
description = models.CharField(max_length=200)
def clean(self):
"""Valida que la línea tenga débito o crédito, pero no ambos"""
if self.debit and self.credit:
raise ValidationError(
'Una línea no puede tener débito y crédito simultáneamente'
)
if not self.debit and not self.credit:
raise ValidationError(
'Debe especificar un valor de débito o crédito'
)
def __str__(self):
return f"{self.account} - D:{self.debit} C:{self.credit}"
2. Configuración del Admin
from django.contrib import admin
from django.utils.html import format_html
@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
list_display = ['code', 'name', 'type', 'formatted_balance']
search_fields = ['code', 'name']
readonly_fields = ['balance']
def formatted_balance(self, obj):
color = 'green' if obj.balance >= 0 else 'red'
return format_html(
'<span style="color: {};">${:,.2f}</span>',
color,
abs(obj.balance)
)
formatted_balance.short_description = 'Balance'
class JournalEntryLineInline(admin.TabularInline):
model = JournalEntryLine
extra = 2
@admin.register(JournalEntry)
class JournalEntryAdmin(admin.ModelAdmin):
list_display = [
'date',
'reference',
'description',
'is_posted'
]
list_filter = ['date', 'is_posted']
search_fields = ['reference', 'description']
inlines = [JournalEntryLineInline]
actions = ['post_entries']
def post_entries(self, request, queryset):
"""Acción para aplicar múltiples asientos"""
for entry in queryset.filter(is_posted=False):
try:
entry.post()
except ValidationError as e:
self.message_user(
request,
f'Error en asiento {entry}: {str(e)}',
level='ERROR'
)
return
self.message_user(
request,
'Asientos aplicados correctamente'
)
post_entries.short_description = "Aplicar asientos seleccionados"
3. Tests
from django.test import TestCase
from django.core.exceptions import ValidationError
from decimal import Decimal
from .models import Account, JournalEntry, JournalEntryLine
class AccountingTestCase(TestCase):
def setUp(self):
# Crear cuentas de prueba
self.cash = Account.objects.create(
code='1001',
name='Caja',
type='ASSET'
)
self.capital = Account.objects.create(
code='3001',
name='Capital',
type='EQUITY'
)
def test_balanced_entry(self):
"""Prueba que un asiento balanceado se aplique correctamente"""
entry = JournalEntry.objects.create(
date='2024-01-01',
reference='TEST-001',
description='Aporte de capital'
)
# Crear líneas del asiento
JournalEntryLine.objects.create(
entry=entry,
account=self.cash,
debit=1000
)
JournalEntryLine.objects.create(
entry=entry,
account=self.capital,
credit=1000
)
# El asiento debería aplicarse sin errores
entry.post()
# Verificar balances
self.cash.refresh_from_db()
self.capital.refresh_from_db()
self.assertEqual(self.cash.balance, Decimal('1000'))
self.assertEqual(self.capital.balance, Decimal('1000'))
def test_unbalanced_entry(self):
"""Prueba que un asiento desbalanceado genere error"""
entry = JournalEntry.objects.create(
date='2024-01-01',
reference='TEST-002',
description='Asiento desbalanceado'
)
JournalEntryLine.objects.create(
entry=entry,
account=self.cash,
debit=1000
)
JournalEntryLine.objects.create(
entry=entry,
account=self.capital,
credit=900 # Desbalanceado intencionalmente
)
# Debería lanzar error al intentar aplicar
with self.assertRaises(ValidationError):
entry.post()
📱 Ejemplo Práctico: Transferencia entre Cuentas
# 1. Crear las cuentas necesarias
cuenta_origen = Account.objects.create(
code='1001',
name='Cuenta Corriente',
type='ASSET'
)
cuenta_destino = Account.objects.create(
code='1002',
name='Cuenta Ahorro',
type='ASSET'
)
# 2. Crear el asiento de transferencia
with transaction.atomic():
asiento = JournalEntry.objects.create(
date='2024-01-01',
reference='TRANS-001',
description='Transferencia entre cuentas'
)
# Débito a cuenta destino
JournalEntryLine.objects.create(
entry=asiento,
account=cuenta_destino,
debit=500,
description='Recepción de fondos'
)
# Crédito a cuenta origen
JournalEntryLine.objects.create(
entry=asiento,
account=cuenta_origen,
credit=500,
description='Envío de fondos'
)
# Aplicar el asiento
asiento.post()
🔒 Mejores Prácticas
-
Validaciones de Seguridad
- Usar
transaction.atomic()
para operaciones múltiples - Implementar permisos granulares en el Admin
- Validar montos negativos y ceros
- Usar
-
Manejo de Errores
- Capturar y loguear errores específicos
- Implementar rollback automático
- Validar estados inconsistentes
-
Patrones de Diseño
- Repository Pattern para consultas complejas
- Observer Pattern para auditoría
- Strategy Pattern para cálculos específicos
Conclusión
Has aprendido a implementar un sistema de partida doble robusto en Django que:
- Asegura que cada movimiento afecte dos o más cuentas
- Valida que débitos y créditos estén balanceados
- Mantiene la integridad de los datos financieros
- Es fácil de auditar y mantener
Siguientes Pasos
- Implementar reportes financieros
- Agregar conciliación bancaria
- Desarrollar un sistema de auditoría
- Añadir validaciones específicas por tipo de cuenta
Top comments (0)