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 | 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
-- 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:
- Usuario navega a Configuración → Contabilidad → Fechas de Cierre
- Sistema muestra fechas actuales de cierre
- Usuario ingresa nueva fecha de cierre fiscal (ej: 2024-12-31)
- Sistema valida:
- Nueva fecha >= fecha actual de cierre
- No hay asientos draft en el período
- Sistema guarda fecha y registra en audit log
- Sistema muestra confirmación
Flujo Alternativo 5a: Hay asientos draft
- Sistema muestra lista de asientos pendientes
- Usuario puede postar o eliminar asientos
- Regresa al paso 4
5.2 UC-LOCK-002: Crear Excepción Temporal
Actor: Contador Precondición: Período ya está cerrado
Flujo Principal:
- Usuario intenta editar asiento en período cerrado
- Sistema bloquea y muestra mensaje de período cerrado
- Usuario solicita excepción a supervisor
- Supervisor navega a Excepciones de Cierre
- 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
- Sistema activa excepción
- 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:
- Usuario navega a Configuración → Fechas de Cierre
- Usuario ingresa fecha de cierre absoluto
- Sistema valida:
- No hay asientos draft
- No hay reconciliaciones pendientes
- Fecha >= hard_lock_date actual
- Sistema muestra advertencia: "Esta acción es IRREVERSIBLE"
- Usuario confirma con contraseña
- Sistema establece hard_lock_date
- 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
# 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