Contabilidad para Django Developers: Períodos Contables

enlabe

Enrique Lazo Bello

Posted on November 8, 2024

Contabilidad para Django Developers: Períodos Contables

Introducción

Como desarrollador Django, probablemente has trabajado con sistemas que manejan datos temporales, pero los períodos contables son diferentes. Imagina un sistema donde necesitas garantizar que todas las operaciones financieras estén correctamente organizadas en períodos de tiempo específicos, y que una vez cerrado un período, los datos sean inmutables.

Este tutorial te guiará en la implementación de un sistema de períodos contables robusto que garantice la integridad de los datos financieros, utilizando únicamente el admin de Django. Aprenderás a manejar la apertura, operación y cierre de períodos contables, junto con todas las validaciones necesarias para mantener la integridad de los datos.

Al finalizar, serás capaz de implementar un sistema que cumpla con los principios básicos de contabilidad mientras mantienes las mejores prácticas de desarrollo en Django.

Prerrequisitos

  • Python 3.12
  • Django 5.0
  • Conocimientos básicos de modelos en Django
  • SQLite o PostgreSQL (recomendado)

Conceptos Clave

Período Contable: La Transacción Definitiva

Piensa en un período contable como una transacción de base de datos a gran escala:

  • La apertura es como iniciar una transacción (BEGIN TRANSACTION)
  • Las operaciones son como los queries dentro de la transacción
  • El cierre es como ejecutar el COMMIT

Estados del Período

Similar a los estados de un objeto en memoria:

  • DRAFT: Como un objeto instanciado pero no guardado
  • ACTIVE: Como un objeto persistido en la base de datos
  • CLOSED: Como un objeto inmutable

Implementación

Modelos Base

from django.db import models
from django.core.exceptions import ValidationError
from django.utils import timezone
from decimal import Decimal
from django.db.models import Sum
from datetime import datetime

class AccountingPeriod(models.Model):
    """
    Representa un período contable con su ciclo de vida completo.
    Similar a una transacción de base de datos a gran escala.
    """
    STATUS_CHOICES = [
        ('DRAFT', 'Borrador'),
        ('ACTIVE', 'Activo'),
        ('CLOSED', 'Cerrado'),
    ]

    name = models.CharField(
        max_length=100,
        unique=True,
        help_text="Ejemplo: 'Enero 2024'"
    )
    start_date = models.DateField()
    end_date = models.DateField()
    status = models.CharField(
        max_length=10,
        choices=STATUS_CHOICES,
        default='DRAFT'
    )
    initial_balance = models.DecimalField(
        max_digits=15,
        decimal_places=2,
        default=Decimal('0.00')
    )
    created_at = models.DateTimeField(auto_now_add=True)
    closed_at = models.DateTimeField(null=True, blank=True)
    closing_notes = models.TextField(blank=True)

    class Meta:
        ordering = ['-start_date']
        constraints = [
            models.CheckConstraint(
                check=models.Q(end_date__gte=models.F('start_date')),
                name='valid_period_dates'
            )
        ]

    def clean(self):
        self._validate_dates()
        self._validate_status_transition()
        self._validate_overlapping()

    def _validate_dates(self):
        """Valida la coherencia de las fechas del período."""
        if self.start_date and self.end_date:
            if self.start_date > self.end_date:
                raise ValidationError(
                    'La fecha de inicio debe ser anterior a la fecha de fin.'
                )

    def _validate_status_transition(self):
        """Valida las transiciones de estado permitidas."""
        if not self.pk:
            return

        old_instance = AccountingPeriod.objects.get(pk=self.pk)
        valid_transitions = {
            'DRAFT': ['ACTIVE'],
            'ACTIVE': ['CLOSED'],
            'CLOSED': []
        }

        if (self.status != old_instance.status and 
            self.status not in valid_transitions[old_instance.status]):
            raise ValidationError(
                f'Transición de estado inválida: {old_instance.status} -> {self.status}'
            )

    def _validate_overlapping(self):
        """Previene la superposición de períodos contables."""
        overlapping = AccountingPeriod.objects.exclude(pk=self.pk).filter(
            models.Q(start_date__lte=self.end_date) & 
            models.Q(end_date__gte=self.start_date)
        )
        if overlapping.exists():
            raise ValidationError(
                'El período se superpone con períodos existentes.'
            )

    def activate(self):
        """Activa un período en estado DRAFT."""
        if self.status != 'DRAFT':
            raise ValidationError('Solo se pueden activar períodos en borrador.')

        self.status = 'ACTIVE'
        self.save()

    def can_close(self):
        """
        Verifica si el período puede ser cerrado.
        Retorna (bool, str) donde bool indica si se puede cerrar
        y str contiene el mensaje de error si no se puede.
        """
        if self.status != 'ACTIVE':
            return False, 'Solo se pueden cerrar períodos activos.'

        # Verificar balance
        if not self.is_balanced():
            return False, 'El período no está balanceado.'

        # Verificar entradas sin procesar
        if self.has_pending_entries():
            return False, 'Existen entradas pendientes de procesar.'

        return True, ''

    def close(self, closing_notes=''):
        """
        Cierra el período si todas las validaciones pasan.
        """
        can_close, message = self.can_close()
        if not can_close:
            raise ValidationError(message)

        self.status = 'CLOSED'
        self.closed_at = timezone.now()
        self.closing_notes = closing_notes
        self.save()

    def is_balanced(self):
        """
        Verifica si los débitos y créditos están balanceados.
        """
        totals = self.entries.aggregate(
            total_debit=Sum('debit_amount'),
            total_credit=Sum('credit_amount')
        )

        total_debit = totals['total_debit'] or Decimal('0.00')
        total_credit = totals['total_credit'] or Decimal('0.00')

        return abs(total_debit - total_credit) < Decimal('0.01')

    def has_pending_entries(self):
        """
        Verifica si hay entradas pendientes de procesar.
        """
        return self.entries.filter(status='PENDING').exists()

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


class AccountingEntry(models.Model):
    """
    Representa una entrada contable individual dentro de un período.
    """
    STATUS_CHOICES = [
        ('PENDING', 'Pendiente'),
        ('PROCESSED', 'Procesado'),
        ('REJECTED', 'Rechazado'),
    ]

    period = models.ForeignKey(
        AccountingPeriod,
        on_delete=models.PROTECT,
        related_name='entries'
    )
    date = models.DateField()
    description = models.CharField(max_length=200)
    debit_amount = models.DecimalField(
        max_digits=15,
        decimal_places=2,
        default=Decimal('0.00')
    )
    credit_amount = models.DecimalField(
        max_digits=15,
        decimal_places=2,
        default=Decimal('0.00')
    )
    status = models.CharField(
        max_length=10,
        choices=STATUS_CHOICES,
        default='PENDING'
    )
    created_at = models.DateTimeField(auto_now_add=True)
    processed_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ['-date', '-created_at']

    def clean(self):
        self._validate_period()
        self._validate_amounts()
        self._validate_date()

    def _validate_period(self):
        """Valida que el período esté activo y la entrada pueda ser modificada."""
        if not self.period_id:
            self.period = self._get_appropriate_period()

        if self.period.status == 'CLOSED':
            raise ValidationError(
                'No se pueden crear o modificar entradas en períodos cerrados.'
            )

    def _validate_amounts(self):
        """Valida que los montos sean coherentes."""
        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(
                'Al menos un monto debe ser mayor a cero.'
            )

    def _validate_date(self):
        """Valida que la fecha esté dentro del período."""
        if not (self.period.start_date <= self.date <= self.period.end_date):
            raise ValidationError(
                'La fecha debe estar dentro del período contable.'
            )

    def _get_appropriate_period(self):
        """Obtiene el período activo adecuado para la fecha de la entrada."""
        period = AccountingPeriod.objects.filter(
            start_date__lte=self.date,
            end_date__gte=self.date,
            status='ACTIVE'
        ).first()

        if not period:
            raise ValidationError(
                'No existe un período activo para la fecha especificada.'
            )
        return period

    def process(self):
        """Procesa la entrada contable."""
        if self.status != 'PENDING':
            raise ValidationError('Solo se pueden procesar entradas pendientes.')

        self.status = 'PROCESSED'
        self.processed_at = timezone.now()
        self.save()

    def reject(self):
        """Rechaza la entrada contable."""
        if self.status != 'PENDING':
            raise ValidationError('Solo se pueden rechazar entradas pendientes.')

        self.status = 'REJECTED'
        self.processed_at = timezone.now()
        self.save()

    def __str__(self):
        return f"{self.date} - {self.description}"
Enter fullscreen mode Exit fullscreen mode

Configuración del Admin

from django.contrib import admin
from django.utils.html import format_html
from django.contrib import messages

@admin.register(AccountingPeriod)
class AccountingPeriodAdmin(admin.ModelAdmin):
    list_display = [
        'name', 
        'start_date', 
        'end_date', 
        'status_display', 
        'balance_display',
        'created_at'
    ]
    list_filter = ['status']
    search_fields = ['name']
    readonly_fields = ['closed_at']

    def status_display(self, obj):
        colors = {
            'DRAFT': '#6c757d',
            'ACTIVE': '#28a745',
            'CLOSED': '#dc3545',
        }
        return format_html(
            '<span style="color: {}; font-weight: bold;">{}</span>',
            colors[obj.status],
            obj.get_status_display()
        )
    status_display.short_description = 'Estado'

    def balance_display(self, obj):
        if obj.is_balanced():
            return format_html(
                '<span style="color: #28a745;">Balanceado</span>'
            )
        return format_html(
            '<span style="color: #dc3545;">Desbalanceado</span>'
        )
    balance_display.short_description = 'Balance'

    def get_readonly_fields(self, request, obj=None):
        if obj and obj.status == 'CLOSED':
            return [f.name for f in obj._meta.fields]
        return self.readonly_fields

    actions = ['activate_periods', 'close_periods']

    def activate_periods(self, request, queryset):
        for period in queryset:
            try:
                period.activate()
                self.message_user(
                    request,
                    f'Período {period.name} activado exitosamente.',
                    messages.SUCCESS
                )
            except ValidationError as e:
                self.message_user(
                    request,
                    f"Error al activar {period.name}: {str(e)}",
                    messages.ERROR
                )
    activate_periods.short_description = "Activar períodos seleccionados"

    def close_periods(self, request, queryset):
        for period in queryset:
            try:
                period.close()
                self.message_user(
                    request,
                    f'Período {period.name} cerrado exitosamente.',
                    messages.SUCCESS
                )
            except ValidationError as e:
                self.message_user(
                    request,
                    f"Error al cerrar {period.name}: {str(e)}",
                    messages.ERROR
                )
    close_periods.short_description = "Cerrar períodos seleccionados"

@admin.register(AccountingEntry)
class AccountingEntryAdmin(admin.ModelAdmin):
    list_display = [
        'date', 
        'description', 
        'debit_amount', 
        'credit_amount',
        'status_display', 
        'period'
    ]
    list_filter = ['period', 'status', 'date']
    search_fields = ['description']
    actions = ['process_entries', 'reject_entries']

    def status_display(self, obj):
        colors = {
            'PENDING': '#ffc107',
            'PROCESSED': '#28a745',
            'REJECTED': '#dc3545',
        }
        return format_html(
            '<span style="color: {}; font-weight: bold;">{}</span>',
            colors[obj.status],
            obj.get_status_display()
        )
    status_display.short_description = 'Estado'

    def get_readonly_fields(self, request, obj=None):
        if obj and (obj.status != 'PENDING' or obj.period.status == 'CLOSED'):
            return [f.name for f in obj._meta.fields]
        return []

    def has_delete_permission(self, request, obj=None):
        if obj and (obj.status != 'PENDING' or obj.period.status == 'CLOSED'):
            return False
        return super().has_delete_permission(request, obj)

    def process_entries(self, request, queryset):
        for entry in queryset:
            try:
                entry.process()
                self.message_user(
                    request,
                    f'Entrada {entry} procesada exitosamente.',
                    messages.SUCCESS
                )
            except ValidationError as e:
                self.message_user(
                    request,
                    f"Error al procesar entrada {entry}: {str(e)}",
                    messages.ERROR
                )
    process_entries.short_description = "Procesar entradas seleccionadas"

## Tests Unitarios

Enter fullscreen mode Exit fullscreen mode


python
from django.test import TestCase
from django.core.exceptions import ValidationError
from decimal import Decimal
from datetime import date, timedelta
from .models import AccountingPeriod, AccountingEntry

class AccountingPeriodTests(TestCase):
def setUp(self):
self.period = AccountingPeriod.objects.create(
name="Test Period",
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31),
status='DRAFT'
)

def test_period_lifecycle(self):
    """Test del ciclo de vida completo de un período"""
    # Verificar estado inicial
    self.assertEqual(self.period.status, 'DRAFT')

    # Activar período
    self.period.activate()
    self.assertEqual(self.period.status, 'ACTIVE')

    # Crear entradas balanceadas
    AccountingEntry.objects.create(
        period=self.period,
        date=date(2024, 1, 15),
        description="Test Entry 1",
        debit_amount=Decimal('100.00'),
        credit_amount=Decimal('0.00'),
        status='PROCESSED'
    )
    AccountingEntry.objects.create(
        period=self.period,
        date=date(2024, 1, 15),
        description="Test Entry 2",
        debit_amount=Decimal('0.00'),
        credit_amount=Decimal('100.00'),
        status='PROCESSED'
    )

    # Cerrar período
    self.period.close()
    self.assertEqual(self.period.status, 'CLOSED')
    self.assertIsNotNone(self.period.closed_at)

def test_overlapping_periods(self):
    """Test de validación de períodos superpuestos"""
    with self.assertRaises(ValidationError):
        AccountingPeriod.objects.create(
            name="Overlapping Period",
            start_date=date(2024, 1, 15),
            end_date=date(2024, 2, 15),
            status='DRAFT'
        )

def test_invalid_status_transition(self):
    """Test de transiciones de estado inválidas"""
    # No se puede pasar directamente de DRAFT a CLOSED
    self.period.status = 'CLOSED'
    with self.assertRaises(ValidationError):
        self.period.save()

    # No se puede reabrir un período cerrado
    self.period.activate()
    self.period.close()
    self.period.status = 'ACTIVE'
    with self.assertRaises(ValidationError):
        self.period.save()
Enter fullscreen mode Exit fullscreen mode

class AccountingEntryTests(TestCase):
def setUp(self):
self.period = AccountingPeriod.objects.create(
name="Test Period",
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31),
status='ACTIVE'
)

def test_entry_validation(self):
    """Test de validaciones básicas de entradas"""
    # Entrada con montos negativos
    with self.assertRaises(ValidationError):
        AccountingEntry.objects.create(
            period=self.period,
            date=date(2024, 1, 15),
            description="Invalid Entry",
            debit_amount=Decimal('-100.00'),
            credit_amount=Decimal('0.00')
        )

    # Entrada fuera del período
    with self.assertRaises(ValidationError):
        AccountingEntry.objects.create(
            period=self.period,
            date=date(2024, 2, 1),
            description="Invalid Entry",
            debit_amount=Decimal('100.00'),
            credit_amount=Decimal('0.00')
        )

def test_entry_processing(self):
    """Test del procesamiento de entradas"""
    entry = AccountingEntry.objects.create(
        period=self.period,
        date=date(2024, 1, 15),
        description="Test Entry",
        debit_amount=Decimal('100.00'),
        credit_amount=Decimal('0.00')
    )

    # Procesar entrada
    entry.process()
    self.assertEqual(entry.status, 'PROCESSED')
    self.assertIsNotNone(entry.processed_at)

    # No se puede procesar una entrada ya procesada
    with self.assertRaises(ValidationError):
        entry.process()

def test_closed_period_modifications(self):
    """Test de modificaciones en período cerrado"""
    self.period.close()

    # No se pueden crear nuevas entradas
    with self.assertRaises(ValidationError):
        AccountingEntry.objects.create(
            period=self.period,
            date=date(2024, 1, 15),
            description="Test Entry",
            debit_amount=Decimal('100.00'),
            credit_amount=Decimal('0.00')
        )
Enter fullscreen mode Exit fullscreen mode

## Ejemplo Práctico: Sistema de Transferencias

Enter fullscreen mode Exit fullscreen mode


python

Ejemplo de uso del sistema

from decimal import Decimal
from datetime import date

def transfer_between_accounts(
period_id: int,
date: date,
amount: Decimal,
description: str
) -> tuple:
"""
Realiza una transferencia entre cuentas creando dos entradas contables.

Returns:
    tuple: (debit_entry, credit_entry)
"""
try:
    period = AccountingPeriod.objects.get(id=period_id)

    # Crear entrada de débito
    debit_entry = AccountingEntry.objects.create(
        period=period,
        date=date,
        description=f"DÉBITO - {description}",
        debit_amount=amount,
        credit_amount=Decimal('0.00')
    )

    # Crear entrada de crédito
    credit_entry = AccountingEntry.objects.create(
        period=period,
        date=date,
        description=f"CRÉDITO - {description}",
        debit_amount=Decimal('0.00'),
        credit_amount=amount
    )

    # Procesar ambas entradas
    debit_entry.process()
    credit_entry.process()

    return debit_entry, credit_entry

except AccountingPeriod.DoesNotExist:
    raise ValidationError("Período contable no encontrado")
except Exception as e:
    raise ValidationError(f"Error en la transferencia: {str(e)}")
Enter fullscreen mode Exit fullscreen mode

Ejemplo de uso:

try:
period = AccountingPeriod.objects.get(
start_date_lte=date(2024, 1, 15),
end_date
_gte=date(2024, 1, 15),
status='ACTIVE'
)

debit, credit = transfer_between_accounts(
    period_id=period.id,
    date=date(2024, 1, 15),
    amount=Decimal('1000.00'),
    description="Transferencia entre cuentas"
)

print(f"Transferencia exitosa: {debit.id}, {credit.id}")
Enter fullscreen mode Exit fullscreen mode

except ValidationError as e:
print(f"Error: {str(e)}")


## Queries Útiles

Enter fullscreen mode Exit fullscreen mode


python

Verificar balance de un período

def check_period_balance(period_id: int) -> dict:
period = AccountingPeriod.objects.get(id=period_id)
totals = period.entries.aggregate(
total_debit=Sum('debit_amount'),
total_credit=Sum('credit_amount')
)

return {
    'period_name': period.name,
    'total_debit': totals['total_debit'] or Decimal('0.00'),
    'total_credit': totals['total_credit'] or Decimal('0.00'),
    'is_balanced': period.is_balanced()
}
Enter fullscreen mode Exit fullscreen mode

Obtener entradas pendientes

def get_pending_entries(period_id: int) -> QuerySet:
return AccountingEntry.objects.filter(
period_id=period_id,
status='PENDING'
)

Obtener resumen de período

def get_period_summary(period_id: int) -> dict:
period = AccountingPeriod.objects.get(id=period_id)
entries_summary = period.entries.aggregate(
total_entries=Count('id'),
pending_entries=Count('id', filter=Q(status='PENDING')),
processed_entries=Count('id', filter=Q(status='PROCESSED')),
rejected_entries=Count('id', filter=Q(status='REJECTED')),
total_debit=Sum('debit_amount'),
total_credit=Sum('credit_amount')
)

return {
    'period_name': period.name,
    'status': period.get_status_display(),
    'duration': (period.end_date - period.start_date).days + 1,
    'total_entries': entries_summary['total_entries'],
    'pending_entries': entries_summary['pending_entries'],
    'processed_entries': entries_summary['processed_entries'],
    'rejected_entries': entries_summary['rejected_entries'],
    'total_debit': entries_summary['total_debit'] or Decimal('0.00'),
    'total_credit': entries_summary['total_credit'] or Decimal('0.00')
}
Enter fullscreen mode Exit fullscreen mode



## Mejores Prácticas

1. **Validaciones de Seguridad**
   - Usar `PROTECT` en foreign keys para prevenir eliminación en cascada
   - Implementar validaciones a nivel de modelo
   - Mantener registro de auditoría de cambios importantes

2. **Manejo de Errores**
   - Usar excepciones específicas y mensajes claros
   - Implementar rollback en operaciones complejas
   - Registrar errores críticos en logs

3. **Patrones de Diseño**
   - State Pattern para estados de período y entradas
   - Factory Method para crear entradas relacionadas
   - Observer Pattern para auditoría y notificaciones

## Conclusión

Este sistema proporciona una base sólida para manejar períodos contables en Django, con:
- Validaciones robustas para mantener la integridad de los datos
- Flujo claro de estados para períodos y entradas
- Sistema extensible para diferentes tipos de operaciones contables
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
enlabe
Enrique Lazo Bello

Posted on November 8, 2024

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

Sign up to receive the latest update from our blog.

Related