DEV Community

Enrique Lazo Bello
Enrique Lazo Bello

Posted on

Contabilidad para Django Developers: Implementando Partida Doble

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

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

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

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

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

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

🔒 Mejores Prácticas

  1. Validaciones de Seguridad

    • Usar transaction.atomic() para operaciones múltiples
    • Implementar permisos granulares en el Admin
    • Validar montos negativos y ceros
  2. Manejo de Errores

    • Capturar y loguear errores específicos
    • Implementar rollback automático
    • Validar estados inconsistentes
  3. 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

  1. Implementar reportes financieros
  2. Agregar conciliación bancaria
  3. Desarrollar un sistema de auditoría
  4. Añadir validaciones específicas por tipo de cuenta

Referencias

Top comments (0)