Contabilidad para Django Developers: Implementando Reglas de Negocio Contables

enlabe

Enrique Lazo Bello

Posted on November 9, 2024

Contabilidad para Django Developers: Implementando Reglas de Negocio Contables

Introducción

¿Alguna vez te has preguntado por qué los contadores insisten tanto en que "el debe igual al haber"? Como desarrollador de Django, probablemente estés más familiarizado con transacciones ACID que con asientos contables. Pero aquí está la sorpresa: los principios contables son muy similares a los conceptos de programación que ya conoces.

En este tutorial, transformaremos conceptos contables en código Django limpio y mantenible. Aprenderás a implementar las reglas de negocio básicas de contabilidad utilizando solo el admin de Django, sin necesidad de crear vistas personalizadas.

Al final de este tutorial, serás capaz de:

  • Implementar validaciones contables robustas
  • Garantizar la integridad de los datos financieros
  • Crear un sistema contable básico pero profesional

Prerrequisitos

# Crear un nuevo ambiente virtual
python -m venv env
source env/bin/activate  # En Windows: env\Scripts\activate

# Instalar dependencias
pip install django==5.0
pip install python-dateutil
Enter fullscreen mode Exit fullscreen mode

Conceptos Clave

1. Asientos Contables = Transacciones Atómicas

Así como una transacción en base de datos debe ser atómica, un asiento contable debe cuadrar (debe = haber). No existe un estado intermedio válido.

2. Período Contable = Ventana de Tiempo Válida

Similar a un rate limiter o una ventana de caché, un período contable define cuándo se pueden realizar operaciones.

3. Plan de Cuentas = Enum Jerárquico

Las cuentas contables son como un enum jerárquico con estados (activa/inactiva) y metadatos.

Implementación Práctica

Primero, definamos nuestros modelos:

# accounting/models.py
from django.db import models
from django.core.exceptions import ValidationError
from django.utils import timezone
from dateutil.relativedelta import relativedelta
import decimal

class AccountingPeriod(models.Model):
    name = models.CharField(max_length=100)
    start_date = models.DateField()
    end_date = models.DateField()
    is_closed = models.BooleanField(default=False)

    class Meta:
        ordering = ['-start_date']

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

    def clean(self):
        if self.start_date > self.end_date:
            raise ValidationError("La fecha de inicio debe ser anterior a la fecha final")

        # Verificar solapamiento de períodos
        overlapping = AccountingPeriod.objects.filter(
            start_date__lte=self.end_date,
            end_date__gte=self.start_date
        ).exclude(pk=self.pk)

        if overlapping.exists():
            raise ValidationError("El período se solapa con otros existentes")

    def is_date_valid(self, date):
        return self.start_date <= date <= self.end_date and not self.is_closed

class Currency(models.Model):
    code = models.CharField(max_length=3, unique=True)
    name = models.CharField(max_length=100)
    symbol = models.CharField(max_length=5)
    is_active = models.BooleanField(default=True)

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

    class Meta:
        verbose_name_plural = "Currencies"

class ExchangeRate(models.Model):
    currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
    date = models.DateField()
    rate = models.DecimalField(max_digits=10, decimal_places=4)

    class Meta:
        unique_together = ['currency', 'date']

    def __str__(self):
        return f"{self.currency.code} - {self.date} - {self.rate}"

    def clean(self):
        if self.rate <= 0:
            raise ValidationError("El tipo de cambio debe ser mayor a 0")

class Account(models.Model):
    ACCOUNT_TYPES = [
        ('A', 'Asset'),
        ('L', 'Liability'),
        ('E', 'Equity'),
        ('I', 'Income'),
        ('X', 'Expense'),
    ]

    code = models.CharField(max_length=20, unique=True)
    name = models.CharField(max_length=200)
    type = models.CharField(max_length=1, choices=ACCOUNT_TYPES)
    is_active = models.BooleanField(default=True)
    allow_movements = models.BooleanField(default=True)
    parent = models.ForeignKey(
        'self',
        null=True,
        blank=True,
        on_delete=models.PROTECT,
        related_name='children'
    )

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

    def clean(self):
        if self.parent:
            if self.parent.type != self.type:
                raise ValidationError("La cuenta padre debe ser del mismo tipo")
            if not self.code.startswith(self.parent.code):
                raise ValidationError("El código debe empezar con el código de la cuenta padre")

class JournalEntry(models.Model):
    date = models.DateField()
    number = models.CharField(max_length=20, unique=True)
    description = models.TextField()
    currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
    is_posted = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.number} - {self.date}"

    def clean(self):
        if not self.pk:  # Solo para nuevos registros
            # Generar número automático
            last_entry = JournalEntry.objects.order_by('-number').first()
            if last_entry:
                last_num = int(last_entry.number)
                self.number = str(last_num + 1).zfill(8)
            else:
                self.number = "00000001"

        # Verificar período contable
        period = AccountingPeriod.objects.filter(
            start_date__lte=self.date,
            end_date__gte=self.date,
            is_closed=False
        ).first()

        if not period:
            raise ValidationError("La fecha no corresponde a un período contable válido y abierto")

        # Verificar tipo de cambio
        if not ExchangeRate.objects.filter(
            currency=self.currency,
            date=self.date
        ).exists():
            raise ValidationError(
                f"No existe tipo de cambio para {self.currency.code} en la fecha {self.date}"
            )

    def save(self, *args, **kwargs):
        if not self.is_posted:
            self.verify_balance()
        super().save(*args, **kwargs)

    def verify_balance(self):
        """Verifica que el asiento cuadre (debe = haber)"""
        if self.pk:  # Solo para entradas existentes
            debit_sum = self.lines.aggregate(
                total=models.Sum('debit_amount')
            )['total'] or 0
            credit_sum = self.lines.aggregate(
                total=models.Sum('credit_amount')
            )['total'] or 0

            if debit_sum != credit_sum:
                raise ValidationError(
                    f"El asiento no cuadra. Debe: {debit_sum}, Haber: {credit_sum}"
                )

class JournalEntryLine(models.Model):
    entry = models.ForeignKey(
        JournalEntry,
        on_delete=models.CASCADE,
        related_name='lines'
    )
    account = models.ForeignKey(Account, on_delete=models.PROTECT)
    description = models.CharField(max_length=200)
    debit_amount = models.DecimalField(
        max_digits=15,
        decimal_places=2,
        default=0
    )
    credit_amount = models.DecimalField(
        max_digits=15,
        decimal_places=2,
        default=0
    )

    def clean(self):
        if not self.account.is_active:
            raise ValidationError("La cuenta no está activa")

        if not self.account.allow_movements:
            raise ValidationError("La cuenta no permite movimientos")

        if self.debit_amount < 0 or self.credit_amount < 0:
            raise ValidationError("Los montos no pueden ser negativos")

        if (self.debit_amount > 0 and self.credit_amount > 0):
            raise ValidationError("Una línea no puede tener debe y haber")

        if (self.debit_amount == 0 and self.credit_amount == 0):
            raise ValidationError("El monto debe ser mayor a 0")
Enter fullscreen mode Exit fullscreen mode

Ahora, configuremos el admin de Django para manejar estos modelos:

# accounting/admin.py
from django.contrib import admin
from django.db.models import Sum
from .models import (
    AccountingPeriod,
    Currency,
    ExchangeRate,
    Account,
    JournalEntry,
    JournalEntryLine
)

@admin.register(AccountingPeriod)
class AccountingPeriodAdmin(admin.ModelAdmin):
    list_display = ['name', 'start_date', 'end_date', 'is_closed']
    search_fields = ['name']
    list_filter = ['is_closed']

@admin.register(Currency)
class CurrencyAdmin(admin.ModelAdmin):
    list_display = ['code', 'name', 'symbol', 'is_active']
    search_fields = ['code', 'name']
    list_filter = ['is_active']

@admin.register(ExchangeRate)
class ExchangeRateAdmin(admin.ModelAdmin):
    list_display = ['currency', 'date', 'rate']
    search_fields = ['currency__code']
    list_filter = ['currency', 'date']

@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
    list_display = [
        'code',
        'name',
        'type',
        'is_active',
        'allow_movements',
        'parent'
    ]
    search_fields = ['code', 'name']
    list_filter = ['type', 'is_active', 'allow_movements']
    ordering = ['code']

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_amount', 'credit_amount']
        return []

@admin.register(JournalEntry)
class JournalEntryAdmin(admin.ModelAdmin):
    list_display = [
        'number',
        'date',
        'description',
        'currency',
        'is_posted',
        'total_amount'
    ]
    search_fields = ['number', 'description']
    list_filter = ['date', 'currency', 'is_posted']
    inlines = [JournalEntryLineInline]
    readonly_fields = ['number']

    def get_readonly_fields(self, request, obj=None):
        if obj and obj.is_posted:
            return ['date', 'number', 'description', 'currency', 'is_posted']
        return ['number']

    def total_amount(self, obj):
        if obj.pk:
            return obj.lines.aggregate(
                total=Sum('debit_amount')
            )['total'] or 0
        return 0

    def save_model(self, request, obj, form, change):
        if not change:  # Nuevo registro
            obj.save()
        else:
            if 'is_posted' in form.changed_data and obj.is_posted:
                # Verificar balance antes de marcar como contabilizado
                obj.verify_balance()
            obj.save()
Enter fullscreen mode Exit fullscreen mode

Ejemplo Real: Sistema de Transferencias entre Cuentas

Veamos cómo implementar una transferencia entre cuentas bancarias:

# accounting/tests.py
from django.test import TestCase
from django.core.exceptions import ValidationError
from decimal import Decimal
from datetime import date
from .models import (
    AccountingPeriod,
    Currency,
    ExchangeRate,
    Account,
    JournalEntry,
    JournalEntryLine
)

class AccountingTests(TestCase):
    def setUp(self):
        # Crear período contable
        self.period = AccountingPeriod.objects.create(
            name="2024-01",
            start_date=date(2024, 1, 1),
            end_date=date(2024, 1, 31)
        )

        # Crear moneda y tipo de cambio
        self.currency = Currency.objects.create(
            code="USD",
            name="US Dollar",
            symbol="$"
        )

        self.exchange_rate = ExchangeRate.objects.create(
            currency=self.currency,
            date=date(2024, 1, 15),
            rate=Decimal('1.0')
        )

        # Crear cuentas bancarias
        self.bank_parent = Account.objects.create(
            code="1001",
            name="Bancos",
            type='A'
        )

        self.bank_account_1 = Account.objects.create(
            code="1001001",
            name="Banco 1",
            type='A',
            parent=self.bank_parent
        )

        self.bank_account_2 = Account.objects.create(
            code="1001002",
            name="Banco 2",
            type='A',
            parent=self.bank_parent
        )

    def test_bank_transfer(self):
        # Crear asiento de transferencia
        entry = JournalEntry.objects.create(
            date=date(2024, 1, 15),
            description="Transferencia entre bancos",
            currency=self.currency
        )

        # Débito al banco 2
        JournalEntryLine.objects.create(
            entry=entry,
            account=self.bank_account_2,
            description="Transferencia recibida",
            debit_amount=Decimal('1000.00')
        )

        # Crédito al banco 1
        JournalEntryLine.objects.create(
            entry=entry,
            account=self.bank_account_1,
            description="Transferencia enviada",
            credit_amount=Decimal('1000.00')
        )

        # Verificar balance
        entry.verify_balance()

        # Marcar como contabilizado
        entry.is_posted = True
        entry.save()


    def test_invalid_transfer(self):
        # Probar transferencia con montos desbalanceados
        entry = JournalEntry.objects.create(
            date=date(2024, 1, 15),
            description="Transferencia inválida",
            currency=self.currency
        )

        JournalEntryLine.objects.create(
            entry=entry,
            account=self.bank_account_2,
            description="Transferencia recibida",
            debit_amount=Decimal('1000.00')
        )

        JournalEntryLine.objects.create(
            entry=entry,
            account=self.bank_account_1,
            description="Transferencia enviada",
            credit_amount=Decimal('900.00')  # Monto diferente
        )

        # Debería levantar una excepción al verificar el balance
        with self.assertRaises(ValidationError):
            entry.verify_balance()

    def test_closed_period(self):
        # Cerrar el período
        self.period.is_closed = True
        self.period.save()

        # Intentar crear un asiento en período cerrado
        with self.assertRaises(ValidationError):
            JournalEntry.objects.create(
                date=date(2024, 1, 15),
                description="Asiento en período cerrado",
                currency=self.currency
            )

    def test_inactive_account(self):
        # Desactivar cuenta
        self.bank_account_1.is_active = False
        self.bank_account_1.save()

        entry = JournalEntry.objects.create(
            date=date(2024, 1, 15),
            description="Transferencia con cuenta inactiva",
            currency=self.currency
        )

        # Intentar crear línea con cuenta inactiva
        with self.assertRaises(ValidationError):
            JournalEntryLine.objects.create(
                entry=entry,
                account=self.bank_account_1,
                description="Débito a cuenta inactiva",
                debit_amount=Decimal('1000.00')
            )
Enter fullscreen mode Exit fullscreen mode

Mejores Prácticas

1. Validaciones de Seguridad

Para garantizar la integridad de los datos contables, hemos implementado múltiples niveles de validación:

# accounting/models.py

class JournalEntry(models.Model):
    # ... (código anterior) ...

    def save(self, *args, **kwargs):
        if self.is_posted:
            raise ValidationError(
                "No se pueden modificar asientos contabilizados"
            )
        super().save(*args, **kwargs)

class JournalEntryLine(models.Model):
    # ... (código anterior) ...

    def save(self, *args, **kwargs):
        if self.entry.is_posted:
            raise ValidationError(
                "No se pueden modificar líneas de asientos contabilizados"
            )
        super().save(*args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

2. Manejo de Errores

Implementamos un middleware personalizado para manejar errores contables de manera amigable:

# accounting/middleware.py
from django.core.exceptions import ValidationError
from django.contrib import messages
from django.shortcuts import redirect

class AccountingErrorMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        return response

    def process_exception(self, request, exception):
        if isinstance(exception, ValidationError):
            messages.error(
                request,
                f"Error de validación contable: {str(exception)}"
            )
            return redirect('admin:index')
        return None
Enter fullscreen mode Exit fullscreen mode

3. Patrones de Diseño Recomendados

  1. Unit of Work: Implementado a través de las transacciones de Django
  2. Repository Pattern: Usando el ORM de Django
  3. Value Objects: Para manejo de montos y tipos de cambio
# accounting/utils.py
from decimal import Decimal
from dataclasses import dataclass
from typing import Optional

@dataclass
class Money:
    amount: Decimal
    currency: str

    def __post_init__(self):
        self.amount = Decimal(str(self.amount))

    def __add__(self, other):
        if not isinstance(other, Money):
            raise TypeError("Solo se pueden sumar objetos Money")
        if self.currency != other.currency:
            raise ValueError("No se pueden sumar montos en diferentes monedas")
        return Money(self.amount + other.amount, self.currency)

    def __sub__(self, other):
        if not isinstance(other, Money):
            raise TypeError("Solo se pueden restar objetos Money")
        if self.currency != other.currency:
            raise ValueError("No se pueden restar montos en diferentes monedas")
        return Money(self.amount - other.amount, self.currency)

@dataclass
class ExchangeRateValue:
    from_currency: str
    to_currency: str
    rate: Decimal
    date: date

    def convert(self, money: Money) -> Money:
        if money.currency != self.from_currency:
            raise ValueError("Moneda incompatible")
        return Money(
            money.amount * self.rate,
            self.to_currency
        )
Enter fullscreen mode Exit fullscreen mode

Consultas Útiles

Aquí hay algunos ejemplos de consultas comunes:

# Obtener balance por cuenta
from django.db.models import Sum, F, Value
from django.db.models.functions import Coalesce

def get_account_balance(account_code, date_from, date_to):
    return JournalEntryLine.objects.filter(
        account__code__startswith=account_code,
        entry__date__range=[date_from, date_to],
        entry__is_posted=True
    ).aggregate(
        balance=Coalesce(
            Sum(F('debit_amount') - F('credit_amount')),
            Value(0)
        )
    )['balance']

# Libro mayor
def get_ledger(account_code, date_from, date_to):
    return JournalEntryLine.objects.filter(
        account__code__startswith=account_code,
        entry__date__range=[date_from, date_to],
        entry__is_posted=True
    ).select_related(
        'entry', 'account'
    ).order_by('entry__date', 'entry__number')

# Balance de comprobación
def get_trial_balance(date_from, date_to):
    return Account.objects.filter(
        lines__entry__date__range=[date_from, date_to],
        lines__entry__is_posted=True
    ).annotate(
        debit_sum=Coalesce(
            Sum('lines__debit_amount'),
            Value(0)
        ),
        credit_sum=Coalesce(
            Sum('lines__credit_amount'),
            Value(0)
        ),
        balance=F('debit_sum') - F('credit_sum')
    ).filter(balance__ne=0)
Enter fullscreen mode Exit fullscreen mode

Conclusión

Hemos construido un sistema contable básico pero robusto utilizando Django. Los puntos clave a recordar son:

  1. Las reglas contables se implementan como validaciones en los modelos
  2. El admin de Django proporciona una interfaz lista para usar
  3. Los tests son cruciales para garantizar la integridad contable
💖 💪 🙅 🚩
enlabe
Enrique Lazo Bello

Posted on November 9, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related