DEV Community

Enrique Lazo Bello
Enrique Lazo Bello

Posted on

Contabilidad para Django Developers: Implementando Partida Doble 🚀

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
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 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'))
Enter fullscreen mode Exit fullscreen mode

Ejemplo de Uso: Registro de un Préstamo Bancario

  1. Primero, crear los tipos de cuenta necesarios desde el admin:

    • Activo (increases_with_debit=True)
    • Pasivo (increases_with_debit=False)
  2. Crear las cuentas:

    • Banco (tipo Activo)
    • Préstamos por Pagar (tipo Pasivo)
  3. 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

  1. Validaciones de Seguridad

    • Usar models.PROTECT en ForeignKeys para evitar eliminación accidental
    • Implementar permisos por grupo en el Admin
    • Validar montos negativos
  2. Manejo de Errores

    • Usar transacciones de base de datos
    • Validar balances antes de aplicar asientos
    • Implementar logs de auditoría
  3. 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)