DEV Community

Enrique Lazo Bello
Enrique Lazo Bello

Posted on

Contabilidad para Django Developers: Implementando Estados Financieros

Introducción

Como desarrollador 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, comparte muchos conceptos similares a los que ya conoces.

En este tutorial, aprenderás a implementar un sistema de estados financieros utilizando Django y su potente admin interface, traduciendo conceptos contables a términos de programación que ya conoces. Por ejemplo, verás cómo un asiento contable es similar a una transacción en una base de datos, y cómo el principio de partida doble es comparable a mantener la integridad referencial.

Al final de este tutorial, serás capaz de implementar un sistema contable básico pero robusto, con validaciones automáticas y tests unitarios, todo desde el Django Admin.

Prerrequisitos

  • Python 3.12
  • Django 5.0
  • Conocimientos básicos de modelos Django y Django Admin
  • SQLite o PostgreSQL para la base de datos

Conceptos Clave

La Contabilidad desde una Perspectiva de Programador

Antes de sumergirnos en el código, vamos a establecer algunas analogías:

  1. Balance General = Estado de la Aplicación

    • Similar a cómo guardamos el estado de una aplicación
    • Activo = Recursos (como RAM o espacio en disco)
    • Pasivo = Recursos comprometidos (como procesos en background)
    • Patrimonio = Recursos netos disponibles
  2. Estado de Resultados = Logs de Operaciones

    • Como un log de transacciones
    • Ingresos = Datos entrantes
    • Gastos = Recursos consumidos
    • Utilidad = Resultado final del proceso

Implementación

Modelos Base

from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import Sum
from decimal import Decimal
import uuid

class AccountType(models.TextChoices):
    ASSET = 'ASSET', 'Activo'
    LIABILITY = 'LIABILITY', 'Pasivo'
    EQUITY = 'EQUITY', 'Patrimonio'
    INCOME = 'INCOME', 'Ingreso'
    EXPENSE = 'EXPENSE', 'Gasto'

class Account(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    code = models.CharField(max_length=20, unique=True)
    name = models.CharField(max_length=100)
    type = models.CharField(max_length=10, choices=AccountType.choices)
    balance = models.DecimalField(max_digits=15, decimal_places=2, default=0)
    parent = models.ForeignKey(
        'self', 
        null=True, 
        blank=True, 
        on_delete=models.PROTECT,
        related_name='children'
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['code']
        indexes = [
            models.Index(fields=['code']),
            models.Index(fields=['type']),
        ]

    def __str__(self):
        return f"{self.code} - {self.name}"

    def clean(self):
        if self.parent and self.parent.type != self.type:
            raise ValidationError('La cuenta padre debe ser del mismo tipo.')

    def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

    @property
    def calculated_balance(self):
        """Calcula el balance incluyendo subcuentas"""
        balance = self.balance
        for child in self.children.all():
            balance += child.calculated_balance
        return balance

class JournalEntry(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    date = models.DateField()
    description = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        verbose_name = 'Asiento Contable'
        verbose_name_plural = 'Asientos Contables'
        ordering = ['-date', '-created_at']

    def __str__(self):
        return f"Asiento #{self.id} - {self.date}"

    def clean(self):
        if hasattr(self, 'entries'):
            debits = sum(entry.debit for entry in self.entries.all())
            credits = sum(entry.credit for entry in self.entries.all())
            if debits != credits:
                raise ValidationError(
                    'Los débitos y créditos deben ser iguales.'
                )

    def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

class JournalEntryLine(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    entry = models.ForeignKey(
        JournalEntry,
        on_delete=models.CASCADE,
        related_name='entries'
    )
    account = models.ForeignKey(
        Account,
        on_delete=models.PROTECT,
        related_name='entries'
    )
    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)

    class Meta:
        verbose_name = 'Línea de Asiento'
        verbose_name_plural = 'Líneas de Asiento'

    def clean(self):
        if self.debit < 0 or self.credit < 0:
            raise ValidationError(
                'Los valores 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 simultáneamente.'
            )
        if self.debit == 0 and self.credit == 0:
            raise ValidationError(
                'Debe especificar un débito o un crédito.'
            )

    def save(self, *args, **kwargs):
        self.full_clean()

        # Actualizar balance de la cuenta
        if self._state.adding:  # Solo para nuevos registros
            if self.debit > 0:
                self.account.balance += self.debit
            else:
                self.account.balance -= self.credit
            self.account.save()

        super().save(*args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

Configuración del Admin

from django.contrib import admin
from django.db import models
from .models import Account, JournalEntry, JournalEntryLine

class AccountAdmin(admin.ModelAdmin):
    list_display = ['code', 'name', 'type', 'balance', 'parent']
    list_filter = ['type']
    search_fields = ['code', 'name']
    readonly_fields = ['balance']

class JournalEntryLineInline(admin.TabularInline):
    model = JournalEntryLine
    extra = 2
    fields = ['account', 'debit', 'credit', 'description']

class JournalEntryAdmin(admin.ModelAdmin):
    list_display = ['id', 'date', 'description', 'total_debit', 'total_credit']
    inlines = [JournalEntryLineInline]

    def total_debit(self, obj):
        return sum(line.debit for line in obj.entries.all())

    def total_credit(self, obj):
        return sum(line.credit for line in obj.entries.all())

admin.site.register(Account, AccountAdmin)
admin.site.register(JournalEntry, JournalEntryAdmin)
Enter fullscreen mode Exit fullscreen mode

Tests Unitarios

from django.test import TestCase
from django.core.exceptions import ValidationError
from decimal import Decimal
from .models import Account, JournalEntry, JournalEntryLine

class AccountingTests(TestCase):
    def setUp(self):
        # Crear cuentas de prueba
        self.cash = Account.objects.create(
            code='1000',
            name='Caja',
            type='ASSET'
        )
        self.bank = Account.objects.create(
            code='1001',
            name='Banco',
            type='ASSET'
        )
        self.capital = Account.objects.create(
            code='3000',
            name='Capital',
            type='EQUITY'
        )

    def test_double_entry_principle(self):
        """Prueba el principio de partida doble"""
        entry = JournalEntry.objects.create(
            date='2024-01-01',
            description='Aporte inicial'
        )

        # Crear líneas de asiento
        JournalEntryLine.objects.create(
            entry=entry,
            account=self.cash,
            debit=Decimal('1000.00')
        )
        JournalEntryLine.objects.create(
            entry=entry,
            account=self.capital,
            credit=Decimal('1000.00')
        )

        # Verificar balances
        self.cash.refresh_from_db()
        self.capital.refresh_from_db()

        self.assertEqual(self.cash.balance, Decimal('1000.00'))
        self.assertEqual(self.capital.balance, Decimal('-1000.00'))

    def test_invalid_entry(self):
        """Prueba validaciones de asientos inválidos"""
        entry = JournalEntry.objects.create(
            date='2024-01-01',
            description='Asiento inválido'
        )

        with self.assertRaises(ValidationError):
            JournalEntryLine.objects.create(
                entry=entry,
                account=self.cash,
                debit=Decimal('-1000.00')  # Valor negativo
            )

Enter fullscreen mode Exit fullscreen mode

Ejemplo Práctico: Sistema de Transferencias

1. Crear las Cuentas Base

Desde el admin de Django, crea las siguientes cuentas:

  • Activos
    • Caja (1000)
    • Banco (1001)
  • Pasivos
    • Cuentas por Pagar (2000)
  • Patrimonio
    • Capital (3000)
  • Ingresos
    • Ventas (4000)
  • Gastos
    • Gastos Operativos (5000)

2. Registrar una Transacción

Para registrar una venta en efectivo:

  1. Crear nuevo JournalEntry
  2. Fecha: La fecha de la transacción
  3. Descripción: "Venta al contado"
  4. Agregar líneas:
    • Débito a Caja (1000) por $100
    • Crédito a Ventas (4000) por $100

3. Verificar Balances

# Desde el shell de Django
from accounting.models import Account

# Verificar balance de caja
caja = Account.objects.get(code='1000')
print(f"Balance de caja: ${caja.balance}")

# Verificar balance de ventas
ventas = Account.objects.get(code='4000')
print(f"Balance de ventas: ${ventas.balance}")
Enter fullscreen mode Exit fullscreen mode

Mejores Prácticas

  1. Validaciones de Seguridad

    • Usar transacciones de base de datos
    • Implementar permisos granulares en el admin
    • Mantener log de cambios
  2. Manejo de Errores

    • Validar balances negativos según tipo de cuenta
    • Verificar referencias circulares en cuentas
    • Implementar rollback en caso de error
  3. Patrones de Diseño

    • Usar Factory Method para crear asientos predefinidos
    • Implementar Observer para actualizar balances
    • Aplicar Command para operaciones complejas

Conclusión

Has aprendido a implementar un sistema contable básico usando Django y su admin interface. Los conceptos clave que debes recordar son:

  1. La contabilidad es similar a mantener estado en tu aplicación
  2. El principio de partida doble garantiza la integridad de los datos
  3. Las validaciones automáticas previenen errores comunes

Top comments (0)