# 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 | Sí |
| **Cierre de Impuestos** | Bloqueo de reportes fiscales (IVA, ISR) | Sí |
| **Cierre de Ventas** | Bloqueo de documentos de venta | Sí |
| **Cierre de Compras** | Bloqueo de documentos de compra | Sí |
| **Cierre Absoluto** | Bloqueo irreversible (hard lock) | No |

---

## 2. Modelo de Datos

### 2.1 Campos de Lock Date en Empresa

```sql
-- 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

```sql
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

```sql
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:

```typescript
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 | ✅ Sí | ✅ Sí | ✅ Sí |
| Cerrado (soft) | ⚠️ Con excepción | ⚠️ Con ajuste de fecha | ⚠️ Con excepción |
| Hard Locked | ❌ No | ❌ No | ❌ No |

---

## 7. API REST

### 7.1 Endpoints

```yaml
# 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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
