erp-core/docs/04-modelado/workflows/WORKFLOW-CIERRE-PERIODO-CONTABLE.md

19 KiB

Workflow: Cierre de Período Contable

Código: WF-FIN-001 Versión: 1.0 Fecha: 2025-12-08 Módulo: MGN-004 (Financiero Básico) Referencia: Odoo account (lock_dates)


1. Resumen

1.1 Propósito

El cierre de período contable permite bloquear la edición de asientos y documentos en períodos pasados, garantizando la integridad de la información financiera una vez que ha sido auditada o reportada.

1.2 Tipos de Cierre

Tipo Descripción Reversible
Cierre Fiscal Bloqueo de fin de año fiscal
Cierre de Impuestos Bloqueo de reportes fiscales (IVA, ISR)
Cierre de Ventas Bloqueo de documentos de venta
Cierre de Compras Bloqueo de documentos de compra
Cierre Absoluto Bloqueo irreversible (hard lock) No

2. Modelo de Datos

2.1 Campos de Lock Date en Empresa

-- Extensión de tabla companies
ALTER TABLE core_auth.companies ADD COLUMN IF NOT EXISTS
    fiscalyear_lock_date DATE,
    tax_lock_date DATE,
    sale_lock_date DATE,
    purchase_lock_date DATE,
    hard_lock_date DATE,
    fiscalyear_last_day INTEGER DEFAULT 31,
    fiscalyear_last_month INTEGER DEFAULT 12;

COMMENT ON COLUMN core_auth.companies.fiscalyear_lock_date IS 'Fecha de cierre del año fiscal (soft lock)';
COMMENT ON COLUMN core_auth.companies.tax_lock_date IS 'Fecha de cierre de impuestos (soft lock)';
COMMENT ON COLUMN core_auth.companies.sale_lock_date IS 'Fecha de cierre de ventas (soft lock)';
COMMENT ON COLUMN core_auth.companies.purchase_lock_date IS 'Fecha de cierre de compras (soft lock)';
COMMENT ON COLUMN core_auth.companies.hard_lock_date IS 'Fecha de cierre absoluto (irreversible)';

2.2 Tabla de Excepciones de Cierre

CREATE TABLE core_financial.lock_exceptions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Relaciones
    company_id UUID NOT NULL REFERENCES core_auth.companies(id),
    user_id UUID REFERENCES core_auth.users(id), -- NULL = excepción global

    -- Configuración de excepción
    lock_date_field VARCHAR(30) NOT NULL
        CHECK (lock_date_field IN (
            'fiscalyear_lock_date',
            'tax_lock_date',
            'sale_lock_date',
            'purchase_lock_date'
        )),
    exception_lock_date DATE NOT NULL, -- Nueva fecha de cierre para el usuario

    -- Vigencia
    end_datetime TIMESTAMPTZ NOT NULL, -- Cuándo expira la excepción

    -- Auditoría
    reason TEXT NOT NULL,
    revoked_at TIMESTAMPTZ,
    revoked_by UUID REFERENCES core_auth.users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID NOT NULL REFERENCES core_auth.users(id),

    -- Estado calculado: 'active', 'expired', 'revoked'
    tenant_id UUID NOT NULL
);

-- Índices
CREATE INDEX idx_lock_exceptions_company ON core_financial.lock_exceptions(company_id);
CREATE INDEX idx_lock_exceptions_user ON core_financial.lock_exceptions(user_id);
CREATE INDEX idx_lock_exceptions_active ON core_financial.lock_exceptions(company_id, end_datetime)
    WHERE revoked_at IS NULL;

COMMENT ON TABLE core_financial.lock_exceptions IS 'Excepciones temporales a los cierres de período';

2.3 Log de Cambios de Cierre

CREATE TABLE core_financial.lock_date_audit (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    company_id UUID NOT NULL REFERENCES core_auth.companies(id),

    -- Qué cambió
    lock_date_field VARCHAR(30) NOT NULL,
    old_value DATE,
    new_value DATE,

    -- Quién y cuándo
    changed_by UUID NOT NULL REFERENCES core_auth.users(id),
    changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    reason TEXT,

    tenant_id UUID NOT NULL
);

COMMENT ON TABLE core_financial.lock_date_audit IS 'Historial de cambios en fechas de cierre';

3. Diagrama de Flujo

┌─────────────────────────────────────────────────────────────────┐
│                    CREAR/EDITAR ASIENTO                         │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  ¿La fecha del asiento está en período cerrado?                 │
│  Verificar: hard_lock_date > fiscalyear > sale/purchase > tax   │
└─────────────────────────────────────────────────────────────────┘
                              │
              ┌───────────────┴───────────────┐
              ▼                               ▼
        [NO - Abierto]                 [SÍ - Cerrado]
              │                               │
              ▼                               ▼
        [Permitir]           ┌────────────────────────────────┐
        [operación]          │ ¿Existe excepción activa       │
                             │ para este usuario?             │
                             └────────────────────────────────┘
                                              │
                              ┌───────────────┴───────────────┐
                              ▼                               ▼
                        [SÍ - Hay                       [NO - Sin
                         excepción]                      excepción]
                              │                               │
                              ▼                               ▼
                   ┌──────────────────┐            ┌──────────────────┐
                   │ Usar fecha de    │            │ Auto-ajustar     │
                   │ excepción como   │            │ fecha contable   │
                   │ nuevo lock_date  │            │ al día siguiente │
                   └──────────────────┘            │ del cierre       │
                              │                    └──────────────────┘
                              │                               │
                              └───────────────┬───────────────┘
                                              ▼
                             ┌────────────────────────────────┐
                             │        PERMITIR OPERACIÓN      │
                             │   (con fecha ajustada si       │
                             │    aplica)                     │
                             └────────────────────────────────┘

4. Reglas de Negocio

4.1 Jerarquía de Lock Dates

Orden de precedencia (de mayor a menor restricción):

1. hard_lock_date     → IRREVERSIBLE, bloquea TODO
2. fiscalyear_lock_date → Cierre de año fiscal
3. sale_lock_date     → Solo documentos de venta
4. purchase_lock_date → Solo documentos de compra
5. tax_lock_date      → Solo líneas que afectan impuestos

4.2 Reglas de Validación

ID Regla Descripción
RN-LOCK-001 Hard lock es irreversible Una vez establecido, no puede reducirse ni eliminarse
RN-LOCK-002 Sin drafts en hard lock No puede establecerse hard_lock_date si hay asientos draft
RN-LOCK-003 Sin reconciliaciones pendientes No puede cerrarse período con líneas bancarias sin conciliar
RN-LOCK-004 Excepciones temporales Las excepciones tienen fecha de expiración obligatoria
RN-LOCK-005 Auditoría obligatoria Todo cambio de lock_date se registra en audit log
RN-LOCK-006 Herencia jerárquica Si empresa padre está cerrada, hijas también

4.3 Auto-Ajuste de Fecha Contable

Cuando un documento tiene fecha en período cerrado:

function getAccountingDate(
  invoiceDate: Date,
  hasTax: boolean,
  company: Company
): Date {
  // Obtener lock dates violados
  const violatedLocks = getViolatedLockDates(invoiceDate, hasTax, company);

  if (violatedLocks.length === 0) {
    return invoiceDate; // Sin ajuste necesario
  }

  // Ajustar al día siguiente del último lock date
  const lastLock = violatedLocks[violatedLocks.length - 1];
  let adjustedDate = addDays(lastLock.date, 1);

  // Considerar tipo de documento y reset de secuencia
  if (isSaleDocument() && sequenceReset === 'month') {
    // Mantener coherencia con secuencias mensuales
    adjustedDate = min(today, endOfMonth(adjustedDate));
  }

  return adjustedDate;
}

5. Casos de Uso

5.1 UC-LOCK-001: Establecer Cierre de Año Fiscal

Actor: Contador / Director Financiero Precondición: Usuario tiene permiso accounting:lock_dates

Flujo Principal:

  1. Usuario navega a Configuración → Contabilidad → Fechas de Cierre
  2. Sistema muestra fechas actuales de cierre
  3. Usuario ingresa nueva fecha de cierre fiscal (ej: 2024-12-31)
  4. Sistema valida:
    • Nueva fecha >= fecha actual de cierre
    • No hay asientos draft en el período
  5. Sistema guarda fecha y registra en audit log
  6. Sistema muestra confirmación

Flujo Alternativo 5a: Hay asientos draft

  1. Sistema muestra lista de asientos pendientes
  2. Usuario puede postar o eliminar asientos
  3. Regresa al paso 4

5.2 UC-LOCK-002: Crear Excepción Temporal

Actor: Contador Precondición: Período ya está cerrado

Flujo Principal:

  1. Usuario intenta editar asiento en período cerrado
  2. Sistema bloquea y muestra mensaje de período cerrado
  3. Usuario solicita excepción a supervisor
  4. Supervisor navega a Excepciones de Cierre
  5. Supervisor crea excepción:
    • Selecciona usuario (o todos)
    • Selecciona tipo de cierre a exceptuar
    • Ingresa nueva fecha de cierre temporal
    • Ingresa fecha/hora de expiración
    • Ingresa motivo
  6. Sistema activa excepción
  7. Usuario puede editar asiento

5.3 UC-LOCK-003: Establecer Hard Lock

Actor: Director Financiero Precondición: Usuario tiene permiso accounting:hard_lock

Flujo Principal:

  1. Usuario navega a Configuración → Fechas de Cierre
  2. Usuario ingresa fecha de cierre absoluto
  3. Sistema valida:
    • No hay asientos draft
    • No hay reconciliaciones pendientes
    • Fecha >= hard_lock_date actual
  4. Sistema muestra advertencia: "Esta acción es IRREVERSIBLE"
  5. Usuario confirma con contraseña
  6. Sistema establece hard_lock_date
  7. Sistema registra en audit log

6. Estados del Período

┌─────────────┐     ┌─────────────┐     ┌─────────────────┐
│   ABIERTO   │────▶│  CERRADO    │────▶│  HARD LOCKED    │
│             │     │  (soft)     │     │  (irreversible) │
└─────────────┘     └─────────────┘     └─────────────────┘
      │                   │
      │     [excepción]   │
      │◀──────────────────┘
Estado Editar Asientos Crear Asientos Revertir
Abierto
Cerrado (soft) ⚠️ Con excepción ⚠️ Con ajuste de fecha ⚠️ Con excepción
Hard Locked No No No

7. API REST

7.1 Endpoints

# Obtener fechas de cierre de empresa
GET /api/v1/companies/{id}/lock-dates
Authorization: Bearer {token}

Response:
{
  "fiscalyear_lock_date": "2024-12-31",
  "tax_lock_date": "2024-03-31",
  "sale_lock_date": null,
  "purchase_lock_date": null,
  "hard_lock_date": "2023-12-31",
  "user_fiscalyear_lock_date": "2024-09-30", // Con excepciones aplicadas
  "user_tax_lock_date": "2024-03-31"
}

# Actualizar fecha de cierre
PUT /api/v1/companies/{id}/lock-dates
Authorization: Bearer {token}

Request:
{
  "fiscalyear_lock_date": "2024-12-31",
  "reason": "Cierre de ejercicio 2024"
}

# Establecer hard lock (acción especial)
POST /api/v1/companies/{id}/lock-dates/hard-lock
Authorization: Bearer {token}

Request:
{
  "hard_lock_date": "2024-12-31",
  "password": "******",
  "reason": "Cierre definitivo ejercicio 2024"
}

# Crear excepción
POST /api/v1/lock-exceptions
Authorization: Bearer {token}

Request:
{
  "company_id": "uuid",
  "user_id": "uuid", // null = para todos
  "lock_date_field": "fiscalyear_lock_date",
  "exception_lock_date": "2024-09-30",
  "end_datetime": "2024-12-20T23:59:59Z",
  "reason": "Corrección de error en factura F-00123"
}

# Revocar excepción
POST /api/v1/lock-exceptions/{id}/revoke
Authorization: Bearer {token}

Request:
{
  "reason": "Corrección completada"
}

# Verificar si fecha está bloqueada
POST /api/v1/lock-dates/check
Authorization: Bearer {token}

Request:
{
  "date": "2024-10-15",
  "journal_type": "sale", // sale, purchase, general
  "has_tax": true
}

Response:
{
  "is_locked": true,
  "violated_locks": [
    {"field": "fiscalyear_lock_date", "date": "2024-12-31"},
    {"field": "sale_lock_date", "date": "2024-10-31"}
  ],
  "adjusted_date": "2025-01-01",
  "can_use_exception": true
}

7.2 Permisos

Endpoint Permiso Requerido
GET lock-dates accounting:read
PUT lock-dates accounting:lock_dates
POST hard-lock accounting:hard_lock
POST lock-exceptions accounting:lock_exceptions
POST revoke accounting:lock_exceptions

8. Integración con Asientos Contables

8.1 Validación en account_move

// En el service de AccountMove

async validateLockDates(move: AccountMove): Promise<void> {
  // Obtener contexto (puede bypasearse en casos especiales)
  if (this.context.bypassLockCheck) return;

  const company = await this.companyService.findById(move.companyId);

  // Determinar qué locks verificar según tipo de diario
  const journalType = move.journal.type;

  const violatedLocks = await this.getViolatedLockDates(
    move.date,
    company,
    {
      fiscalyear: true,
      sale: journalType === 'sale',
      purchase: journalType === 'purchase',
      tax: false, // Se valida por línea
      hard: true,
    }
  );

  if (violatedLocks.length > 0) {
    throw new LockDateViolationError(
      `No puede crear/modificar asientos antes de: ${violatedLocks[0].date}`,
      violatedLocks
    );
  }
}

async validateTaxLockDate(line: AccountMoveLine): Promise<void> {
  if (line.move.state !== 'posted') return;
  if (!line.affectsTaxReport()) return;

  const violatedLocks = await this.getViolatedLockDates(
    line.move.date,
    line.move.company,
    { tax: true, hard: true }
  );

  if (violatedLocks.length > 0) {
    throw new LockDateViolationError(
      'Operación rechazada: impacta declaración de impuestos',
      violatedLocks
    );
  }
}

8.2 Hook de Pre-Save

// Hook que se ejecuta antes de guardar cualquier asiento

@BeforeInsert()
@BeforeUpdate()
async validateAccountMove(): Promise<void> {
  // 1. Validar lock dates generales
  await this.lockDateService.validateLockDates(this);

  // 2. Si está posteado, validar tax lock en líneas
  if (this.state === 'posted') {
    for (const line of this.lines) {
      await this.lockDateService.validateTaxLockDate(line);
    }
  }
}

9. Consideraciones de Implementación

9.1 Cache de Lock Dates

// Cachear lock dates por usuario para evitar queries repetidos
interface UserLockDatesCache {
  userId: string;
  companyId: string;
  fiscalyearLockDate: Date;
  taxLockDate: Date;
  saleLockDate: Date;
  purchaseLockDate: Date;
  hardLockDate: Date;
  computedAt: Date;
  ttl: number; // segundos
}

// Invalidar cache cuando:
// 1. Cambia lock_date de empresa
// 2. Se crea/revoca excepción
// 3. Expira excepción

9.2 Transacciones y Bloqueos

// Al establecer hard_lock_date, usar transacción con bloqueo
async setHardLockDate(
  companyId: string,
  date: Date,
  reason: string
): Promise<void> {
  await this.db.transaction(async (trx) => {
    // Bloquear empresa para evitar cambios concurrentes
    const company = await trx('companies')
      .where('id', companyId)
      .forUpdate()
      .first();

    // Validar que no hay drafts
    const drafts = await trx('account_moves')
      .where('company_id', companyId)
      .where('state', 'draft')
      .where('date', '<=', date)
      .count();

    if (drafts > 0) {
      throw new Error(`Hay ${drafts} asientos draft en el período`);
    }

    // Validar que no hay reconciliaciones pendientes
    // ... (similar)

    // Establecer hard lock
    await trx('companies')
      .where('id', companyId)
      .update({
        hard_lock_date: date,
        updated_at: new Date(),
      });

    // Registrar en audit log
    await trx('lock_date_audit').insert({
      company_id: companyId,
      lock_date_field: 'hard_lock_date',
      old_value: company.hard_lock_date,
      new_value: date,
      changed_by: this.currentUser.id,
      reason,
    });
  });
}

10. Mensajes de Error

Código Mensaje Descripción
LOCK_001 "El período está cerrado" Fecha del documento en período cerrado
LOCK_002 "Cierre fiscal activo hasta {date}" Detalle de fiscalyear_lock_date
LOCK_003 "Período de impuestos cerrado" Impacta tax_lock_date
LOCK_004 "Cierre absoluto activo" hard_lock_date aplicado
LOCK_005 "No puede reducir hard_lock_date" Intento de reducir cierre irreversible
LOCK_006 "Hay asientos pendientes" Drafts impiden cierre
LOCK_007 "Reconciliaciones pendientes" Líneas sin conciliar

11. Testing

11.1 Casos de Prueba

ID Caso Precondición Acción Resultado Esperado
TC-001 Crear asiento en período abierto Sin lock_dates Crear asiento OK
TC-002 Crear asiento en período cerrado fiscalyear_lock_date=2024-12-31 Crear con fecha 2024-10-15 Error LOCK_002
TC-003 Excepción activa Excepción para usuario Crear asiento OK
TC-004 Excepción expirada end_datetime pasado Crear asiento Error LOCK_002
TC-005 Hard lock hard_lock_date=2024-12-31 Crear asiento 2024-10-15 Error LOCK_004
TC-006 Reducir hard lock hard_lock_date=2024-12-31 Cambiar a 2024-06-30 Error LOCK_005

12. Referencias

  • Odoo company.py: Lock date fields y validaciones
  • Odoo account_move.py: _check_fiscal_lock_dates()
  • Odoo account_lock_exception.py: Sistema de excepciones
  • MGN-004: Módulo Financiero Básico
  • SPEC-TRANS-001: Sistema de Secuencias

Documento creado por: Requirements-Analyst Fecha: 2025-12-08