DEV Community

Enrique Lazo Bello
Enrique Lazo Bello

Posted on

Contabilidad para Django Developers: Implementando Libros Contables

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

  1. 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)
  2. 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)
  3. 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']
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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:

  1. La contabilidad es similar a un sistema de logging transaccional
  2. Las validaciones son cruciales para mantener la integridad de los datos
  3. El patrón Command nos ayuda a encapsular operaciones contables complejas

Top comments (0)