DEV Community

Enrique Lazo Bello
Enrique Lazo Bello

Posted on

Contabilidad para Django Developers: Implementando un Plan de Cuentas

Contabilidad para Django Developers: Implementando un Plan de Cuentas Jerárquico

Introducción

Si has trabajado con bases de datos jerárquicas o estructuras de árbol en programación, entonces ya tienes la base mental para entender un Plan de Cuentas contable. Imagina un sistema de archivos: tienes directorios principales (Grupos), subdirectorios (Clases), y archivos (Cuentas), cada uno con sus propias características y restricciones.

En este tutorial, traduciremos conceptos contables a términos de programación, implementando un Plan de Cuentas robusto que maneje la jerarquía natural de la contabilidad: Grupo → Clase → Cuenta → Subcuenta. Aprenderás a crear un sistema que mantenga la integridad de los datos contables mientras preserva las relaciones jerárquicas entre cuentas.

Prerrequisitos

# Crear entorno virtual
python -m venv env
source env/bin/activate

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

Conceptos Clave

La Jerarquía Contable como Estructura de Árbol

# Analogía con sistema de archivos
root/
  ├── activos/              # Grupo
     ├── corrientes/       # Clase
        ├── efectivo/     # Cuenta
           ├── caja      # Subcuenta
           └── bancos    # Subcuenta
        └── inventario
     └── fijos/
  ├── pasivos/
  └── patrimonio/
Enter fullscreen mode Exit fullscreen mode

Implementación en Django

from decimal import Decimal, ROUND_HALF_UP
from django.db import models
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator

class AccountLevel(models.TextChoices):
    """Niveles jerárquicos del plan de cuentas"""
    GROUP = 'G', 'Grupo'
    CLASS = 'C', 'Clase'
    ACCOUNT = 'A', 'Cuenta'
    SUBACCOUNT = 'S', 'Subcuenta'

class AccountType(models.TextChoices):
    """Tipos principales de cuentas"""
    ASSET = 'A', 'Activo'
    LIABILITY = 'P', 'Pasivo'
    EQUITY = 'E', 'Patrimonio'
    INCOME = 'I', 'Ingreso'
    EXPENSE = 'G', 'Gasto'

class AccountNature(models.TextChoices):
    """Naturaleza de la cuenta (determina su comportamiento)"""
    DEBIT = 'D', 'Deudora'
    CREDIT = 'C', 'Acreedora'

class Account(models.Model):
    """
    Modelo principal para el Plan de Cuentas.
    Implementa una estructura jerárquica recursiva.
    """
    code = models.CharField(
        max_length=20,
        unique=True,
        help_text="Código único de la cuenta (ej: 1.1.1.01)"
    )
    name = models.CharField(
        max_length=100,
        help_text="Nombre descriptivo de la cuenta"
    )
    level = models.CharField(
        max_length=1,
        choices=AccountLevel.choices,
        help_text="Nivel jerárquico de la cuenta"
    )
    type = models.CharField(
        max_length=1,
        choices=AccountType.choices,
        help_text="Tipo de cuenta"
    )
    nature = models.CharField(
        max_length=1,
        choices=AccountNature.choices,
        help_text="Naturaleza de la cuenta (Deudora/Acreedora)"
    )
    parent = models.ForeignKey(
        'self',
        null=True,
        blank=True,
        on_delete=models.PROTECT,
        related_name='children'
    )
    is_transactional = models.BooleanField(
        default=False,
        help_text="Indica si la cuenta puede recibir transacciones"
    )
    balance = models.DecimalField(
        max_digits=15,
        decimal_places=2,
        default=Decimal('0.00'),
        validators=[
            MinValueValidator(Decimal('-999999999999.99')),
            MaxValueValidator(Decimal('999999999999.99'))
        ],
        help_text="Saldo actual de la cuenta"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    active = models.BooleanField(default=True)

    class Meta:
        verbose_name = 'Cuenta Contable'
        verbose_name_plural = 'Cuentas Contables'
        ordering = ['code']

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

    def clean(self):
        """Validaciones de negocio para la cuenta"""
        self._validate_hierarchy()
        self._validate_code_format()
        self._validate_account_type()
        self._validate_transaction_rules()

    def _validate_hierarchy(self):
        """Valida la jerarquía de la cuenta"""
        if self.parent:
            # Validar nivel jerárquico
            level_order = {
                'G': 0, 'C': 1, 'A': 2, 'S': 3
            }
            if level_order[self.level] <= level_order[self.parent.level]:
                raise ValidationError(
                    'El nivel jerárquico debe ser mayor al del padre'
                )

            # Validar tipo de cuenta
            if self.type != self.parent.type:
                raise ValidationError(
                    'El tipo de cuenta debe coincidir con el de su padre'
                )
        else:
            # Solo grupos pueden no tener padre
            if self.level != 'G':
                raise ValidationError(
                    'Solo los grupos pueden no tener cuenta padre'
                )

    def _validate_code_format(self):
        """Valida el formato del código según el nivel"""
        code_patterns = {
            'G': r'^\d$',                    # Un dígito: 1
            'C': r'^\d\.\d{2}$',            # Tres dígitos: 1.01
            'A': r'^\d\.\d{2}\.\d{2}$',     # Cinco dígitos: 1.01.01
            'S': r'^\d\.\d{2}\.\d{2}\.\d{2}$' # Siete dígitos: 1.01.01.01
        }

        if not re.match(code_patterns[self.level], self.code):
            raise ValidationError(
                f'Formato de código inválido para nivel {self.get_level_display()}'
            )

    def _validate_account_type(self):
        """Valida la naturaleza según el tipo de cuenta"""
        nature_by_type = {
            'A': 'D',  # Activos son deudores
            'P': 'C',  # Pasivos son acreedores
            'E': 'C',  # Patrimonio es acreedor
            'I': 'C',  # Ingresos son acreedores
            'G': 'D'   # Gastos son deudores
        }

        if self.nature != nature_by_type[self.type]:
            raise ValidationError(
                f'La naturaleza no corresponde al tipo de cuenta'
            )

    def _validate_transaction_rules(self):
        """Valida reglas para cuentas transaccionales"""
        if self.is_transactional:
            if self.level != 'S':
                raise ValidationError(
                    'Solo las subcuentas pueden ser transaccionales'
                )
            if self.children.exists():
                raise ValidationError(
                    'Una cuenta transaccional no puede tener subcuentas'
                )
        elif self.level == 'S':
            raise ValidationError(
                'Las subcuentas deben ser transaccionales'
            )

    def update_balance(self):
        """Actualiza el saldo de la cuenta y propaga hacia arriba"""
        if self.is_transactional:
            # Calcular saldo desde las transacciones
            transactions = self.transactions.all()
            debits = sum(t.amount for t in transactions if t.entry_type == 'D')
            credits = sum(t.amount for t in transactions if t.entry_type == 'C')

            # Aplicar según naturaleza de la cuenta
            if self.nature == 'D':
                self.balance = debits - credits
            else:
                self.balance = credits - debits
        else:
            # Para cuentas no transaccionales, sumar saldos de hijos
            self.balance = sum(child.balance for child in self.children.all())

        self.save()

        # Propagar hacia arriba
        if self.parent:
            self.parent.update_balance()

    @property
    def absolute_balance(self):
        """Retorna el saldo considerando la naturaleza de la cuenta"""
        if self.nature == 'D':
            return self.balance
        return -self.balance

    def get_descendants(self):
        """Retorna todas las cuentas descendientes"""
        descendants = []
        for child in self.children.all():
            descendants.append(child)
            descendants.extend(child.get_descendants())
        return descendants

    def get_ancestors(self):
        """Retorna todas las cuentas ancestras"""
        ancestors = []
        parent = self.parent
        while parent:
            ancestors.append(parent)
            parent = parent.parent
        return ancestors
Enter fullscreen mode Exit fullscreen mode

Configuración del Admin

from django.contrib import admin
from .models import Account

@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
    list_display = [
        'code', 'name', 'level', 'type', 'nature',
        'is_transactional', 'balance', 'active'
    ]
    list_filter = ['level', 'type', 'nature', 'is_transactional', 'active']
    search_fields = ['code', 'name']
    readonly_fields = ['balance', 'created_at', 'updated_at']

    def get_readonly_fields(self, request, obj=None):
        if obj:  # editing an existing object
            return self.readonly_fields + ['code', 'level', 'type', 'nature']
        return self.readonly_fields

    def get_queryset(self, request):
        return super().get_queryset(request).select_related('parent')
Enter fullscreen mode Exit fullscreen mode

Tests Unitarios

from django.test import TestCase
from django.core.exceptions import ValidationError
from decimal import Decimal
from .models import Account

class AccountTests(TestCase):
    def setUp(self):
        # Crear estructura básica
        self.assets = Account.objects.create(
            code='1',
            name='Activos',
            level='G',
            type='A',
            nature='D'
        )

        self.current_assets = Account.objects.create(
            code='1.01',
            name='Activos Corrientes',
            level='C',
            type='A',
            nature='D',
            parent=self.assets
        )

    def test_hierarchy_validation(self):
        """Prueba las validaciones jerárquicas"""
        with self.assertRaises(ValidationError):
            # Intentar crear una clase sin padre
            Account.objects.create(
                code='2.01',
                name='Invalid',
                level='C',
                type='A',
                nature='D'
            )

    def test_code_format(self):
        """Prueba la validación de formato de código"""
        with self.assertRaises(ValidationError):
            Account.objects.create(
                code='1.1',  # Formato inválido para clase
                name='Invalid',
                level='C',
                type='A',
                nature='D',
                parent=self.assets
            )

    def test_balance_propagation(self):
        """Prueba la propagación de saldos"""
        # Crear cuenta transaccional
        cash = Account.objects.create(
            code='1.01.01.01',
            name='Caja General',
            level='S',
            type='A',
            nature='D',
            parent=self.current_assets,
            is_transactional=True
        )

        # Simular transacción
        cash.balance = Decimal('1000.00')
        cash.save()
        cash.update_balance()

        # Verificar propagación
        self.current_assets.refresh_from_db()
        self.assets.refresh_from_db()

        self.assertEqual(self.current_assets.balance, Decimal('1000.00'))
        self.assertEqual(self.assets.balance, Decimal('1000.00'))
Enter fullscreen mode Exit fullscreen mode

Ejemplo Real: Creación del Plan de Cuentas Base

def create_base_chart_of_accounts():
    """Crea una estructura básica del plan de cuentas"""
    # Activos
    assets = Account.objects.create(
        code='1',
        name='Activos',
        level='G',
        type='A',
        nature='D'
    )

    current_assets = Account.objects.create(
        code='1.01',
        name='Activos Corrientes',
        level='C',
        type='A',
        nature='D',
        parent=assets
    )

    cash = Account.objects.create(
        code='1.01.01',
        name='Efectivo y Equivalentes',
        level='A',
        type='A',
        nature='D',
        parent=current_assets
    )

    Account.objects.create(
        code='1.01.01.01',
        name='Caja General',
        level='S',
        type='A',
        nature='D',
        parent=cash,
        is_transactional=True
    )

    # Pasivos
    liabilities = Account.objects.create(
        code='2',
        name='Pasivos',
        level='G',
        type='P',
        nature='C'
    )

    # ... continuar con la estructura
Enter fullscreen mode Exit fullscreen mode

Mejores Prácticas

  1. Validaciones de Seguridad

    • Proteger la integridad jerárquica
    • Validar formatos de código
    • Controlar la naturaleza de las cuentas
    • Prevenir modificaciones no autorizadas
  2. Manejo de Errores

    • Validaciones específicas por nivel
    • Mensajes de error claros
    • Control de saldos y balances
  3. Patrones de Diseño

    • Composite para estructura jerárquica
    • Observer para actualización de saldos
    • Template Method para validaciones

Conclusión

Has aprendido a implementar un Plan de Cuentas que:

  • Mantiene una estructura jerárquica sólida
  • Valida la integridad de los datos contables
  • Propaga correctamente los saldos
  • Permite una gestión eficiente desde el Admin de Django
  • Implementa validaciones robustas de negocio
  • Mantiene la consistencia de los datos contables

Top comments (0)