# 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 { // 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 { 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 { // 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 { 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