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}"
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
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()
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')
)
## Ejemplo Práctico: Sistema de Transferencias
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)}")
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}")
except ValidationError as e:
print(f"Error: {str(e)}")
## Queries Útiles
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()
}
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')
}
## 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