Introducción
¿Alguna vez te has preguntado por qué los sistemas contables son tan rigurosos con la validación de datos? Como desarrollador, probablemente estés familiarizado con las transacciones en bases de datos - donde todo debe ser consistente o nada se ejecuta. La contabilidad por partida doble funciona de manera similar: cada transacción debe mantener el sistema en equilibrio, justo como tus transacciones ACID en PostgreSQL.
En este tutorial, aprenderás a implementar un sistema contable profesional utilizando Django, enfocándonos en el concepto de partida doble. Lo mejor de todo: explicaremos los conceptos contables usando analogías que ya conoces como desarrollador.
Prerrequisitos
- Python 3.8+
- Django 5.0+
- Conocimientos básicos de modelos Django
- SQLite o PostgreSQL
Conceptos Clave: Contabilidad para Developers
La Partida Doble Explicada con Git
Piensa en la partida doble como un sistema de control de versiones para dinero. Así como en Git cada commit debe estar balanceado (los archivos añadidos/modificados deben corresponder exactamente con los cambios en el repositorio), en contabilidad cada transacción debe estar balanceada.
# En Git:
git add archivo1.py (+100 líneas)
git add archivo2.py (-100 líneas)
# El repositorio mantiene su balance
# En Contabilidad:
Banco += 1000 # Débito
Capital -= 1000 # Crédito
# Las cuentas mantienen su balance
Implementación en Django
Primero, definamos nuestros modelos:
from django.db import models
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.contrib import admin
from decimal import Decimal
class AccountType(models.Model):
"""
Tipo de cuenta contable (Activo, Pasivo, Capital, Ingreso, Gasto)
"""
name = models.CharField(max_length=50)
# True para cuentas que aumentan con débito (Activos, Gastos)
increases_with_debit = models.BooleanField(
help_text="True para cuentas que aumentan con débito (Activos, Gastos)"
)
def __str__(self):
return self.name
class Meta:
verbose_name = "Tipo de Cuenta"
verbose_name_plural = "Tipos de Cuenta"
class Account(models.Model):
"""
Cuenta contable (ej: Banco, Caja, Capital, etc.)
"""
code = models.CharField(
max_length=20,
unique=True,
help_text="Código único de la cuenta"
)
name = models.CharField(max_length=100)
type = models.ForeignKey(AccountType, on_delete=models.PROTECT)
balance = models.DecimalField(
max_digits=15,
decimal_places=2,
default=0,
editable=False
)
is_active = models.BooleanField(default=True)
def __str__(self):
return f"{self.code} - {self.name}"
class Meta:
verbose_name = "Cuenta"
verbose_name_plural = "Cuentas"
ordering = ['code']
class JournalEntry(models.Model):
"""
Asiento contable (conjunto de movimientos que deben balancearse)
"""
date = models.DateField()
concept = models.CharField(max_length=200)
reference = models.CharField(max_length=50, blank=True)
is_posted = models.BooleanField(
default=False,
editable=False,
help_text="Indica si el asiento ya fue aplicado a las cuentas"
)
def __str__(self):
return f"{self.date} - {self.concept}"
def clean(self):
# Validar que el asiento esté balanceado
if self.pk: # Solo validar si ya existe
debits = sum(line.debit for line in self.lines.all())
credits = sum(line.credit for line in self.lines.all())
if debits != credits:
raise ValidationError(
_('El asiento debe estar balanceado. '
f'Débitos: {debits}, Créditos: {credits}')
)
def post(self):
"""Aplica el asiento a las cuentas afectadas"""
if self.is_posted:
raise ValidationError(_('Este asiento ya fue aplicado'))
for line in self.lines.all():
account = line.account
if account.type.increases_with_debit:
account.balance += (line.debit - line.credit)
else:
account.balance += (line.credit - line.debit)
account.save()
self.is_posted = True
self.save()
class Meta:
verbose_name = "Asiento Contable"
verbose_name_plural = "Asientos Contables"
class JournalEntryLine(models.Model):
"""
Línea de asiento contable (movimiento individual)
"""
entry = models.ForeignKey(
JournalEntry,
on_delete=models.CASCADE,
related_name='lines'
)
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, blank=True)
def clean(self):
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}"
class Meta:
verbose_name = "Línea de Asiento"
verbose_name_plural = "Líneas de Asiento"
Ahora, configuremos el Admin de Django para manejar estas entidades:
from django.contrib import admin
from django.utils.html import format_html
@admin.register(AccountType)
class AccountTypeAdmin(admin.ModelAdmin):
list_display = ['name', 'increases_with_debit']
search_fields = ['name']
@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
list_display = ['code', 'name', 'type', 'formatted_balance', 'is_active']
list_filter = ['type', 'is_active']
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', 'concept', 'reference', 'is_posted']
list_filter = ['date', 'is_posted']
search_fields = ['concept', 'reference']
inlines = [JournalEntryLineInline]
readonly_fields = ['is_posted']
actions = ['post_entries']
def post_entries(self, request, queryset):
for entry in queryset:
if not entry.is_posted:
try:
entry.post()
except ValidationError as e:
self.message_user(
request,
f"Error al aplicar asiento {entry}: {str(e)}",
level='ERROR'
)
return
self.message_user(request, "Asientos aplicados exitosamente")
post_entries.short_description = "Aplicar asientos seleccionados"
Tests Unitarios
from django.test import TestCase
from django.core.exceptions import ValidationError
from decimal import Decimal
from .models import AccountType, Account, JournalEntry, JournalEntryLine
class AccountingTest(TestCase):
def setUp(self):
# Crear tipos de cuenta
self.asset_type = AccountType.objects.create(
name='Activo',
increases_with_debit=True
)
self.liability_type = AccountType.objects.create(
name='Pasivo',
increases_with_debit=False
)
# Crear cuentas
self.bank = Account.objects.create(
code='1001',
name='Banco',
type=self.asset_type
)
self.loan = Account.objects.create(
code='2001',
name='Préstamo',
type=self.liability_type
)
def test_journal_entry_balance(self):
"""Prueba que los asientos deban estar balanceados"""
entry = JournalEntry.objects.create(
date='2024-01-01',
concept='Préstamo bancario'
)
# Crear líneas desbalanceadas
JournalEntryLine.objects.create(
entry=entry,
account=self.bank,
debit=Decimal('1000.00')
)
JournalEntryLine.objects.create(
entry=entry,
account=self.loan,
credit=Decimal('900.00')
)
# Debe lanzar error al validar
with self.assertRaises(ValidationError):
entry.clean()
def test_posting_updates_balances(self):
"""Prueba que al aplicar un asiento se actualicen los balances"""
entry = JournalEntry.objects.create(
date='2024-01-01',
concept='Préstamo bancario'
)
JournalEntryLine.objects.create(
entry=entry,
account=self.bank,
debit=Decimal('1000.00')
)
JournalEntryLine.objects.create(
entry=entry,
account=self.loan,
credit=Decimal('1000.00')
)
entry.post()
self.bank.refresh_from_db()
self.loan.refresh_from_db()
self.assertEqual(self.bank.balance, Decimal('1000.00'))
self.assertEqual(self.loan.balance, Decimal('1000.00'))
Ejemplo de Uso: Registro de un Préstamo Bancario
-
Primero, crear los tipos de cuenta necesarios desde el admin:
- Activo (increases_with_debit=True)
- Pasivo (increases_with_debit=False)
-
Crear las cuentas:
- Banco (tipo Activo)
- Préstamos por Pagar (tipo Pasivo)
-
Crear un asiento para registrar el préstamo:
- Fecha: 2024-01-01
- Concepto: "Préstamo bancario recibido"
- Líneas:
- Débito a Banco por $10,000
- Crédito a Préstamos por Pagar por $10,000
Mejores Prácticas
-
Validaciones de Seguridad
- Usar
models.PROTECT
en ForeignKeys para evitar eliminación accidental - Implementar permisos por grupo en el Admin
- Validar montos negativos
- Usar
-
Manejo de Errores
- Usar transacciones de base de datos
- Validar balances antes de aplicar asientos
- Implementar logs de auditoría
-
Patrones de Diseño
- Usar el patrón Observer para actualizaciones de balance
- Implementar Command para operaciones reversibles
- Aplicar Repository para consultas complejas
Conclusión
Has aprendido a implementar un sistema contable básico pero robusto usando Django. Los conceptos clave son:
- La partida doble es como un sistema de control de versiones para dinero
- Cada transacción debe estar balanceada
- Las validaciones son cruciales para mantener la integridad
Top comments (0)