Appearance
Eliminar Recibo y Orden de Pago
⚠️ DOCUMENTACIÓN RETROSPECTIVA — Generada a partir de código implementado el 2026-06-18
Módulo: CtaCte Tipo: Process Estado: Implementado Fecha: 2026-06-18
Descripción
Permite eliminar un Recibo de Cobranza (cuenta tipo Deudor) o una Orden de Pago (cuenta tipo Acreedor) ya registrado en la cuenta corriente. La eliminación es una reversión completa: restaura los saldos de los comprobantes cancelados, revierte los movimientos de tesorería, deshace retenciones y maneja los cheques de terceros. La operación se ejecuta dentro de una única transacción y es bloqueada cuando la reversión sería insegura.
Reglas de Negocio
REQ-01: Aislamiento de modo de registro (prueba / oficial)
Solo es posible eliminar un recibo desde una sesión con el mismo modo de registro en que fue creado. Un recibo de modo prueba no puede eliminarse desde una sesión oficial y viceversa. Este es un control de integridad de datos, no un control de autorización.
REQ-02: Idempotencia — recibo no encontrado
Si el registro ordcta no existe (ya fue eliminado o el ID es incorrecto), el sistema responde con un error claro de "no encontrado" (HTTP 404) sin mutar ningún estado.
REQ-03: Bloqueo — pago parcial no último
La eliminación se bloquea si algún comprobante cancelado por el recibo tiene un pago posterior registrado en un recibo diferente. La operación es inviable hasta que el pago posterior sea eliminado primero. El mensaje identifica el comprobante bloqueante.
Aplica a: Deudor y Acreedor.
REQ-04: Bloqueo — cheque propio conciliado (OrdenPago)
Si la Orden de Pago incluye un cheque propio (vía rectra / iteban) que ya fue conciliado en Tesorería (iteban.conciliado = true), la eliminación se bloquea. El operador debe desconciliar manualmente el cheque en Tesorería antes de poder eliminar la orden.
Aplica a: Acreedor únicamente.
REQ-05: Bloqueo — cheque de tercero reutilizado (OrdenPago)
Si el cheque de tercero egresado por la Orden de Pago fue posteriormente reutilizado en una orden de fecha igual o posterior, la eliminación se bloquea. El sistema verifica todas las filas recche para ese cheque y compara fechas de las órdenes asociadas.
Aplica a: Acreedor únicamente.
REQ-06: Tesorería — caja abierta, eliminación directa
Cuando el movimiento de caja (movimi) asociado al recibo se encuentra en una caja abierta, el registro movimi se elimina directamente (hard delete). No se genera ningún contra-asiento.
REQ-07: Tesorería — caja cerrada, contra-asiento
Cuando el movimiento de caja se encuentra en una caja cerrada, se requiere confirmación explícita del usuario antes de proceder. Confirmado, se inserta un nuevo movimiento movimi con tipo invertido (E↔S) y fecha de la operación de eliminación, en la caja actualmente abierta de ese schema. Los registros del período cerrado no se modifican.
REQ-08: Restauración de saldos de comprobantes
Para cada comprobante cancelado por el recibo (recfac), se restaura el saldo al valor snapshot guardado en recfac.saldo y se limpia el campo pago. La actualización apunta al schema del comprobante (recfac._schema), lo que garantiza la correcta restauración en escenarios cross-schema.
REQ-09: Cheque de tercero — path Deudor (decisión de cartera)
Cuando un Recibo incluye un cheque de tercero no egresado (terceros.fecegr es NULL), el registro terceros se elimina junto con la relación recche. Cuando el cheque ya fue egresado (fecegr tiene valor), el frontend pregunta al usuario "¿mantener cheque en cartera?":
- Mantener: solo se elimina la relación
recche; el registrotercerospermanece con sufecegroriginal. - Descartar: se eliminan tanto
terceroscomorecche.
REQ-10: Cheque de tercero — path Acreedor (retorno a cartera)
Cuando una Orden de Pago incluye un cheque de tercero, la eliminación siempre limpia terceros.fecegr y terceros.prov (retorno a cartera), una vez que el chequeo de reutilización (REQ-05) haya pasado. La relación recche se elimina por el paso general de relaciones.
REQ-11: Reversión de retenciones de ganancias (OrdenPago)
Si la Orden de Pago tiene retenciones de ganancias (detgan), la eliminación:
- Revierte el movimiento
movimide ingreso asociado a cada retención (con lógica de caja abierta/cerrada). - Elimina el movimiento
ordctaDEBE de retención. - Decrementa el acumulador mensual
acugan.monretpor el monto retenido. - Elimina todas las filas
detgande la orden.
Cuando el módulo COMPRAS no está activo para el tenant, este paso se omite completamente.
Aplica a: Acreedor únicamente.
REQ-12: Reversión de retenciones de cobranza (Recibo)
Si el Recibo tiene filas de retención de cobranza (recret), la eliminación:
- Revierte el movimiento
movimide egreso asociado a cada retención (con lógica de caja abierta/cerrada). - Elimina el movimiento
ordctaHABER de retención.
Las filas recret en sí son eliminadas por el paso general de relaciones (paso 11 del pipeline).
Aplica a: Deudor únicamente.
REQ-13: Registro de auditoría
Cada eliminación exitosa genera una entrada en el log de auditoría que incluye el ID del recibo, el usuario que realizó la acción, el timestamp de eliminación y el tipo (RECIBO u ORDEN_PAGO). El registro se escribe dentro de la transacción, antes del commit.
Frontend
Flujo de confirmación en dos fases
La interfaz implementa un flujo SweetAlert2 secuencial antes de enviar la operación al backend.
Fase 1 — confirmación inicial El usuario hace clic en "Eliminar". Se muestra un diálogo de confirmación estándar: "¿Eliminar recibo Nº X? Esta acción es irreversible." Si confirma, el frontend llama al endpoint de pre-check.
Fase 2 — diálogos condicionales según respuesta del pre-check
| Condición devuelta | Acción del frontend |
|---|---|
blocks[] con algún error | Muestra alerta informativa (no de confirmación) con el mensaje específico. No se envía la operación de eliminación. |
warnings[].code = CLOSED_CAJA | Muestra segundo diálogo de advertencia explicando que se generará un contra-asiento con fecha de hoy. Solo se continúa si el usuario confirma (confirmClosedCaja: true). |
warnings[].code = CHEQUE_EGRESADO | Muestra diálogo preguntando "¿Mantener el cheque en cartera?" La respuesta se envía como chequeDecision: 'keep' o 'discard'. |
| Sin bloques ni advertencias | Se envía la operación directamente. |
Visibilidad del botón de eliminar La opción de eliminar solo se renderiza cuando el usuario tiene el permiso correspondiente, verificado a través del mecanismo existente js/auth/permisos.js (getPermisosUsuario()). El backend no realiza ninguna verificación de autorización.
Endpoints consumidos
Pre-check (lectura, sin mutación)
GET /mod-ctacte/recibos/{id}/pre-check?tipo_cuenta=D|ARespuesta:
json
{
"status": 200,
"data": {
"blocks": [
{ "code": "PAGO_PARCIAL_NO_ULTIMO", "message": "..." }
],
"warnings": [
{ "code": "CLOSED_CAJA", "message": "...", "meta": { "nrocaj": 3 } },
{ "code": "CHEQUE_EGRESADO", "message": "...", "meta": { "id_cheque": 42 } }
]
}
}Eliminación (mutación)
DELETE /mod-ctacte/recibos/{id}Body:
json
{
"tipo_cuenta": { "id": 1 },
"chequeDecision": "keep" | "discard" | null,
"confirmClosedCaja": true | false,
"prueba": false
}Respuesta exitosa: { "status": 200, "data": { "deleted": true } }
Backend
Capa HTTP
ReciboDeleteController (controller/modulo-ctacte/Recibo/ReciboDeleteController.php) es el controlador delgado. Sus responsabilidades son:
- Leer el body de la solicitud.
- Invocar
TipoCuentaQueryService::resolveRecordById()para obtener el registro real demulctadesde la base de datos, incluyendotabla_movimientoseid. Eltipodel cliente es ignorado (guarda W2: la tabla y el mulcta siempre provienen de DB, nunca del request body). - Seleccionar el servicio correcto según el
tiporesuelto:TipoCuenta::DEUDOR→ReciboDeleteServiceTipoCuenta::ACREEDOR→OrdenPagoDeleteService
- Delegar a
deleteWithDecisions($id, $chequeDecision, $confirmClosedCaja)para la eliminación, o aprecheck($id)para la inspección sin mutación.
El validator ReciboDeleteValidator valida estructuralmente los campos chequeDecision (nullable, in:keep,discard) y confirmClosedCaja (nullable, boolean).
Template method — pipeline de 14 pasos
AbstractReciboDeleteService::delete() implementa el algoritmo como método final. Los pasos son:
| Paso | Descripción | Fase |
|---|---|---|
| 1 | Cargar fila ordcta por ID | Pre-mutación |
| 2 | Lanzar ReciboNoEncontradoException si no existe (404) | Pre-mutación |
| 3 | assertRecordMode() — guardia prueba/oficial | Pre-mutación |
| 4 | Descubrir relaciones multi-schema (recfac, recche, rectra) | Pre-mutación |
| 5 | Ejecutar block checks (getBlockChecks()) — hook abstracto | Pre-mutación |
| 6 | beginTransaction('principal', 'oficial') | Transacción |
| 7 | restoreComprobanteSaldos() — per recfac._schema | Transacción |
| 8 | reverseRetentions() — hook abstracto | Transacción |
| 9 | reverseTesoreria() — open: delete; closed: contra-asiento | Transacción |
| 10 | handleChequesTerceros() — hook abstracto | Transacción |
| 11 | deleteRelationRows() — hijos antes que padre, por schema | Transacción |
| 12 | deleteOrdcta() — fila padre en schema_origen | Transacción |
| 13 | registrarAuditoria() | Transacción |
| 14 | commit() | Transacción |
En cualquier excepción durante los pasos 6–14 se ejecuta rollBack().
Orden de eliminación de hijos (paso 11):
recret— retenciones (schema por defecto)recfac— relaciones de comprobante (per_schema)recche— relaciones de cheque (per_schema)rectra— relaciones de transferencia (per_schema)reccom— comentarios (schema por defecto)
Hooks abstractos por subclase:
| Hook | ReciboDeleteService (Deudor) | OrdenPagoDeleteService (Acreedor) |
|---|---|---|
getBlockChecks() | Pago parcial no último | Cheque propio conciliado + cheque tercero reutilizado + pago parcial no último |
reverseRetentions() | Revierte movimi egreso + ordcta HABER por cada recret | Revierte movimi ingreso + ordcta DEBE + decrementa acugan.monret + elimina detgan |
handleChequesTerceros() | No egresado → elimina terceros; egresado → decisión (keep/discard) | Siempre limpia fecegr/prov en terceros (returnToCartera) |
Estrategia cross-schema y límite de transacción
beginTransaction('principal', 'oficial')abre transacciones en ambas conexiones lógicas; en la arquitectura multi-tenant de PostgreSQL, los schemas son namespaces dentro de la misma base de datos, por lo que todos los writes cross-schema son atómicos bajo la misma transacción.discoverRelations(multiSchema: true)usaReciboRelationsServiceconCrossSchemaQueryable: cada DTO de relación retorna con el campo_schemapoblado.setSchemaContext($schema)/resetSchemaContext()en bloquefinallyenvuelve cada operación para garantizar que elsearch_pathse restaura aunque ocurra una excepción.- El campo
ordcta.schema_origendetermina el schema de la fila padre.
Excepciones de dominio
| Excepción | HTTP | Mensaje |
|---|---|---|
ReciboNoEncontradoException | 404 | El recibo {id} no existe o ya fue eliminado. |
RecordModeMismatchException | 400 | Mismatch entre modo de sesión y modo del registro. |
PagoParcialNoUltimoException | 400 | El comprobante {id} tiene pagos posteriores a este recibo. |
ChequePropioConciliadoException | 400 | El cheque propio está conciliado — desconciliarlo manualmente. |
ChequeTerceroReusadoException | 400 | El cheque fue utilizado en una orden de pago posterior. |
InconsistenciaRetencionException | 500 | El movimiento DEBE de retención no existe (inconsistencia de datos). |
Todas las excepciones extienden BadRequest y son despachadas automáticamente por el BadRequestErrorHandler central.
Consideraciones Técnicas
schema_origenenordctadetermina el schema target para el lookup demovimi/cajay para la eliminación del padre. No se confía en el schema del request body para ubicar la fila.- Campo
_schemaen DTOs — poblado por el traitCrossSchemaQueryabledurante el descubrimiento de relaciones — es la fuente de verdad para el schema context de cada operación de write por fila. tabla_movimientosse resuelve siempre desde el registro demulctaen la DB (nunca desde el request body) para prevenir inyección de tabla (guarda W2).deleteWithDecisions()vsdelete()— la API pública preferida para el controller esdeleteWithDecisions(), que incluye el gate W1 de caja cerrada.delete()es el entry point sin decisiones de usuario (pasachequeDecision: nully omite el gate W1).- Guards de módulo —
moduloActivo(Modulo::TESORERIA)protege todo acceso amovimi/caja/terceros;moduloActivo(Modulo::COMPRAS)protege el acceso adetgan/acugan. Si el módulo no está activo para el tenant, las tablas no existen y el paso se omite. - Eliminación hard delete —
ordctano tiene columnadeleted_at; la eliminación es definitiva, consistente conFacturaService::delete()yCuentaCorriente::delete().
Referencias
- Spec SDD:
openspec/changes/eliminar-recibos-ordenes-pago-ctacte/specs/ctacte/spec.md - Design SDD:
openspec/changes/eliminar-recibos-ordenes-pago-ctacte/design.md - Diagrama de flujo: eliminar-recibo-orden-pago-flowchart.md
- Implementación PR1 (Tesorería primitivos):
service/Tesoreria/MovimientoCajaService.php - Servicio abstract:
service/CtaCte/AbstractReciboDeleteService.php - Servicio Deudor:
service/CtaCte/ReciboDeleteService.php - Servicio Acreedor:
service/CtaCte/OrdenPagoDeleteService.php - Controller:
controller/modulo-ctacte/Recibo/ReciboDeleteController.php - Validator:
Validators/CtaCte/ReciboDeleteValidator.php
⚠️ NOTA IMPORTANTE: Validar con stakeholders antes de considerar final. Esta documentación fue generada retrospectivamente a partir del código implementado en la rama
feature/recibos-ctacte-pr1-tesoreria.