Skip to content

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 registro terceros permanece con su fecegr original.
  • Descartar: se eliminan tanto terceros como recche.

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:

  1. Revierte el movimiento movimi de ingreso asociado a cada retención (con lógica de caja abierta/cerrada).
  2. Elimina el movimiento ordcta DEBE de retención.
  3. Decrementa el acumulador mensual acugan.monret por el monto retenido.
  4. Elimina todas las filas detgan de 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:

  1. Revierte el movimiento movimi de egreso asociado a cada retención (con lógica de caja abierta/cerrada).
  2. Elimina el movimiento ordcta HABER 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 devueltaAcción del frontend
blocks[] con algún errorMuestra alerta informativa (no de confirmación) con el mensaje específico. No se envía la operación de eliminación.
warnings[].code = CLOSED_CAJAMuestra 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_EGRESADOMuestra diálogo preguntando "¿Mantener el cheque en cartera?" La respuesta se envía como chequeDecision: 'keep' o 'discard'.
Sin bloques ni advertenciasSe 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|A

Respuesta:

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 de mulcta desde la base de datos, incluyendo tabla_movimientos e id. El tipo del 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 tipo resuelto:
    • TipoCuenta::DEUDORReciboDeleteService
    • TipoCuenta::ACREEDOROrdenPagoDeleteService
  • Delegar a deleteWithDecisions($id, $chequeDecision, $confirmClosedCaja) para la eliminación, o a precheck($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:

PasoDescripciónFase
1Cargar fila ordcta por IDPre-mutación
2Lanzar ReciboNoEncontradoException si no existe (404)Pre-mutación
3assertRecordMode() — guardia prueba/oficialPre-mutación
4Descubrir relaciones multi-schema (recfac, recche, rectra)Pre-mutación
5Ejecutar block checks (getBlockChecks()) — hook abstractoPre-mutación
6beginTransaction('principal', 'oficial')Transacción
7restoreComprobanteSaldos() — per recfac._schemaTransacción
8reverseRetentions() — hook abstractoTransacción
9reverseTesoreria() — open: delete; closed: contra-asientoTransacción
10handleChequesTerceros() — hook abstractoTransacción
11deleteRelationRows() — hijos antes que padre, por schemaTransacción
12deleteOrdcta() — fila padre en schema_origenTransacción
13registrarAuditoria()Transacción
14commit()Transacción

En cualquier excepción durante los pasos 6–14 se ejecuta rollBack().

Orden de eliminación de hijos (paso 11):

  1. recret — retenciones (schema por defecto)
  2. recfac — relaciones de comprobante (per _schema)
  3. recche — relaciones de cheque (per _schema)
  4. rectra — relaciones de transferencia (per _schema)
  5. reccom — comentarios (schema por defecto)

Hooks abstractos por subclase:

HookReciboDeleteService (Deudor)OrdenPagoDeleteService (Acreedor)
getBlockChecks()Pago parcial no últimoCheque propio conciliado + cheque tercero reutilizado + pago parcial no último
reverseRetentions()Revierte movimi egreso + ordcta HABER por cada recretRevierte 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) usa ReciboRelationsService con CrossSchemaQueryable: cada DTO de relación retorna con el campo _schema poblado.
  • setSchemaContext($schema) / resetSchemaContext() en bloque finally envuelve cada operación para garantizar que el search_path se restaura aunque ocurra una excepción.
  • El campo ordcta.schema_origen determina el schema de la fila padre.

Excepciones de dominio

ExcepciónHTTPMensaje
ReciboNoEncontradoException404El recibo {id} no existe o ya fue eliminado.
RecordModeMismatchException400Mismatch entre modo de sesión y modo del registro.
PagoParcialNoUltimoException400El comprobante {id} tiene pagos posteriores a este recibo.
ChequePropioConciliadoException400El cheque propio está conciliado — desconciliarlo manualmente.
ChequeTerceroReusadoException400El cheque fue utilizado en una orden de pago posterior.
InconsistenciaRetencionException500El 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_origen en ordcta determina el schema target para el lookup de movimi/caja y para la eliminación del padre. No se confía en el schema del request body para ubicar la fila.
  • Campo _schema en DTOs — poblado por el trait CrossSchemaQueryable durante el descubrimiento de relaciones — es la fuente de verdad para el schema context de cada operación de write por fila.
  • tabla_movimientos se resuelve siempre desde el registro de mulcta en la DB (nunca desde el request body) para prevenir inyección de tabla (guarda W2).
  • deleteWithDecisions() vs delete() — la API pública preferida para el controller es deleteWithDecisions(), que incluye el gate W1 de caja cerrada. delete() es el entry point sin decisiones de usuario (pasa chequeDecision: null y omite el gate W1).
  • Guards de módulomoduloActivo(Modulo::TESORERIA) protege todo acceso a movimi/caja/terceros; moduloActivo(Modulo::COMPRAS) protege el acceso a detgan/acugan. Si el módulo no está activo para el tenant, las tablas no existen y el paso se omite.
  • Eliminación hard deleteordcta no tiene columna deleted_at; la eliminación es definitiva, consistente con FacturaService::delete() y CuentaCorriente::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.