10 KiB
US-MGN-004-003-003: Cancelar Asiento con Reversing Entry
RF Asociado: RF-MGN-004-003 Módulo: MGN-004 - Financiero Básico Epic: Asientos Contables Prioridad: P0 (MVP) Story Points: 3 Sprint: Sprint 8 Estado: Ready for Development Fecha: 2025-11-24
User Story
Como contador, Quiero cancelar un asiento contable publicado creando un asiento de reversión, Para corregir errores contables sin eliminar el registro original, manteniendo auditoría completa.
Descripción Detallada
En contabilidad, los asientos publicados NO se pueden editar ni eliminar. Para corregirlos, se crea un asiento de reversión (reversing entry) que invierte los débitos y créditos del asiento original.
Ejemplo: Asiento Original:
- 1.1.01.001 (Caja) | Debe: 1000 | Haber: 0
- 4.1.01.001 (Ingresos) | Debe: 0 | Haber: 1000
Asiento de Reversión:
- 1.1.01.001 (Caja) | Debe: 0 | Haber: 1000
- 4.1.01.001 (Ingresos) | Debe: 1000 | Haber: 0
El efecto neto es que el asiento original queda cancelado. Ambos asientos quedan registrados para auditoría.
Criterios de Aceptación
Escenario 1: Crear asiento de reversión exitosamente
Dado que tengo un asiento posted con id=10, Cuando ejecuto POST /api/v1/financial/journal-entries/10/reverse con reason="Error en monto", Entonces el sistema:
- Crea un nuevo asiento con líneas invertidas (débitos ↔ créditos)
- Asigna state='posted' automáticamente
- Vincula el asiento de reversión al original (reversed_entry_id)
- Marca el asiento original como reversed=true
- Retorna el asiento de reversión creado
Escenario 2: No se puede revertir asiento draft
Dado que tengo un asiento con state='draft', Cuando intento revertirlo, Entonces el sistema retorna error 400 "Solo se pueden revertir asientos publicados".
Escenario 3: No se puede revertir asiento ya revertido
Dado que tengo un asiento con reversed=true, Cuando intento revertirlo nuevamente, Entonces el sistema retorna error 409 "Este asiento ya fue revertido".
Escenario 4: Asiento de reversión invierte correctamente débitos y créditos
Dado que el asiento original tiene línea: account_id=1, debit=1000, credit=0, Cuando creo la reversión, Entonces el asiento de reversión tiene línea: account_id=1, debit=0, credit=1000.
Escenario 5: Referencia del asiento de reversión
Dado que reverso el asiento "VEN/2024/0001", Cuando se crea el asiento de reversión, Entonces su referencia es "Reversión de VEN/2024/0001: [reason]".
Reglas de Negocio
- RN-1: Solo asientos con state='posted' pueden revertirse.
- RN-2: Un asiento solo puede revertirse una vez (prevenir reversiones múltiples).
- RN-3: El asiento de reversión se crea automáticamente en state='posted' (no pasa por draft).
- RN-4: Las líneas del asiento de reversión son idénticas pero con débitos y créditos invertidos.
- RN-5: El asiento de reversión tiene la fecha contable actual (no la del asiento original).
- RN-6: Se requiere motivo de reversión (reason) para auditoría.
- RN-7: Los balances de cuentas se actualizan automáticamente al postear la reversión.
- RN-8: Ambos asientos (original + reversión) quedan registrados permanentemente.
Tareas Técnicas
Backend
- Endpoint:
POST /api/v1/financial/journal-entries/:id/reverse - DTO:
ReverseJournalEntryDto(reason: string required) - Service:
JournalEntryService.reverse(id, reason, userId) - Service:
JournalEntryService.createReversingEntry(originalEntry, reason) - Validar que entry.state = 'posted'
- Validar que entry.reversed = false
- Crear nuevo asiento con líneas invertidas
- Marcar asiento original reversed=true, reversed_by=reversing_entry_id
- Asignar reversing_entry.reversed_entry_id = original_entry_id
- Postear asiento de reversión automáticamente (actualizar balances)
- Unit tests (8 test cases)
- Integration tests (6 test cases)
- Swagger docs
Frontend
- Botón: "Cancelar Asiento" (visible solo si state='posted' y reversed=false)
- Modal:
ReverseEntryModal.tsx(solicitar motivo) - Campo: Motivo de reversión (textarea, required, min 10 chars)
- Badge: Mostrar "Revertido" si reversed=true
- Link: "Ver Asiento de Reversión" (navegar al reversing entry)
- API client:
journalEntryApi.reverse(id, reason) - Toast: "Asiento revertido exitosamente"
- Component test: ReverseEntryModal.test.tsx
- E2E test: "should reverse posted entry successfully"
Database
- Campo:
journal_entries.reversed(boolean, default false) - Campo:
journal_entries.reversed_entry_id(uuid nullable, FK a journal_entries) - Campo:
journal_entries.reverse_reason(text nullable) - Índice: idx_journal_entries_reversed
- Check constraint: reversed_entry_id solo puede asignarse si reversed=true
Mockups / Wireframes
Botón Cancelar en Asiento Posted:
┌──────────────────────────────────────────┐
│ Asiento VEN/2024/0001 [Publicado ✓] │
│ [Ver PDF] [Cancelar Asiento] │
├──────────────────────────────────────────┤
│ ...detalles del asiento... │
└──────────────────────────────────────────┘
Modal Cancelar Asiento:
┌──────────────────────────────────────────┐
│ ⚠️ Cancelar Asiento │
├──────────────────────────────────────────┤
│ Está a punto de cancelar el asiento │
│ VEN/2024/0001. Esto creará un asiento │
│ de reversión que anulará los efectos │
│ contables del asiento original. │
│ │
│ Motivo de cancelación: (requerido) │
│ ┌────────────────────────────────────┐ │
│ │ Error en monto facturado │ │
│ │ │ │
│ └────────────────────────────────────┘ │
│ │
│ [Volver] [Sí, Cancelar] │
└──────────────────────────────────────────┘
Asiento Revertido (Vista Original):
┌──────────────────────────────────────────┐
│ Asiento VEN/2024/0001 [Revertido ⚠️] │
│ [Ver Asiento de Reversión →] │
├──────────────────────────────────────────┤
│ Este asiento fue revertido el │
│ 2024-01-20 por Juan Pérez │
│ Motivo: Error en monto facturado │
└──────────────────────────────────────────┘
Casos de Prueba
Funcionales
- TC-001: Reversar asiento posted exitosamente
- TC-002: Error por asiento draft
- TC-003: Error por asiento ya revertido
- TC-004: Líneas invertidas correctamente (debit ↔ credit)
- TC-005: Referencia incluye motivo de reversión
- TC-006: Asiento original marcado reversed=true
- TC-007: Balances actualizados correctamente
- TC-008: Vincular asientos (reversed_entry_id, reversed_by)
No Funcionales
- Performance: < 500ms para crear reversión
- Atomicidad: Rollback si falla al crear reversión
- Seguridad: Solo accounting_manager puede reversar
Dependencias
- US bloqueantes:
- US-MGN-004-003-002 (Postear Asiento)
- Módulos: MGN-004
Notas de Implementación
- Auditoría completa: Ambos asientos (original + reversión) quedan registrados permanentemente
- Fecha de reversión: Usar fecha actual del sistema, NO la fecha del asiento original
- Número secuencial: El asiento de reversión obtiene el siguiente número del journal
- Journal: La reversión usa el mismo journal que el asiento original
- Validación de motivo: Mínimo 10 caracteres para asegurar descripción clara
- Frontend: Prevenir doble-click en botón "Cancelar" (loading state)
Estimación Detallada
| Tarea | Estimación |
|---|---|
| Backend | 2 horas |
| Frontend | 2 horas |
| Testing | 1.5 horas |
| Code Review | 0.5 hora |
| TOTAL | 6 horas = 3 SP |
Definition of Done
- Código backend implementado
- Código frontend implementado
- Unit tests pasando (>80%)
- Integration tests pasando
- E2E tests pasando
- Validaciones funcionan (solo posted, no revertidos 2 veces)
- Líneas invertidas correctamente
- Balances actualizados
- Motivo de reversión requerido
- Code review aprobado
- Swagger docs actualizado
- Merge a develop
- QA validado
- PO aprobado