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

AWS Security LIVE!

Join us for AWS Security LIVE!

Discover the future of cloud security. Tune in live for trends, tips, and solutions from AWS and AWS Partners.

Learn More

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay