Skip to content

Fase 5 — Reconciliación de Pagos Portal (ERP)

Módulo: Portal de Clientes — ERP Integration Tipo: Process Estado: Implementado Fecha: 2026-04-27

⚠️ DOCUMENTACIÓN RETROSPECTIVA — Generada a partir de código implementado el 2026-04-27.


Descripción

Los pagos online iniciados desde el Portal de Clientes son aprobados por gateways externos (PayPerTIC / MercadoPago) y persistidos en la tabla portal_payments (schema EMPRESA / public) con status='approved'. Sin embargo, esa aprobación no impacta la cuenta corriente del cliente: el recibo en ordcta (schema SUCURSAL suc{NNNN}) lo genera el operador del ERP de manera explícita.

La Fase 5 implementa esa pasarela operador-mediada: la conciliación manual que toma un portal_payments.id aprobado, crea un movimiento de tipo Recibo (id_tipo=4) en ordcta de la sucursal activa, vincula las facturas en recfac, actualiza pago/saldo de los comprobantes y marca recibo_id en portal_payments — todo dentro de una transacción atómica multi-conexión (principal + oficial).

El feature toca tres capas de schemas distintos en una única operación:

  • portal_payments (EMPRESA/public) — origen del pago online
  • ordcta, recfac, mulcta, ordcon (SUCURSAL suc{NNNN}) — destino contable
  • Auditoría (audit log)

Endpoints ERP

Base path: /backend/portal-erp. Todas las rutas montadas en Modules/Portal/Infrastructure/Http/Routes/PortalErpRoutes.php.

Middleware aplicado al grupo (en orden): AuthMiddleware ERP (JWT RSA) + ConnectionMiddleware ERP. Se diferencia explícitamente del JWT del portal de clientes — estos endpoints son solo para operadores del ERP, no para clientes finales.

MétodoPathController methodStatus OKBody request
GET/pagos/pendienteslistPendientes()200
GET/pagos/historiallistHistorial()200
POST/pagos/{paymentId:[0-9]+}/conciliarconciliar()201(vacío)

El regex [0-9]+ en paymentId rechaza IDs no numéricos a nivel de ruta.

Autenticación y schema

  • JWT ERP: Header Authorization: Bearer <token> validado por AuthMiddleware (clave RSA pública del ERP). No es el mismo JWT que usa el Portal de Clientes.
  • Schema sucursal: header X-Schema: suc0001 (o suc0002, etc.) — extraído por PortalErpController::extractSucursalId(). Si el payload JWT trae un schema distinto de 'public', gana sobre el header. Si no hay schema válido, el controller responde 400 X_SCHEMA_REQUIRED.

GET /pagos/pendientes

Lista paginada de pagos status='approved' de la sucursal activa cuyo recibo_id IS NULL. Hace JOIN con ordcon para incluir el cliente_nombre.

Query params: page (default 1, min 1), limit (default 20, max 100).

Respuesta (200):

CampoTipo
successtrue
data.data[]array de pagos con id, ordcon_id, sucursal_id, cliente_nombre, monto, gateway_payment_id, reference, approved_at, recibo_id
data.totaltotal filas que cumplen el filtro
data.page / data.limitparámetros de paginación

GET /pagos/historial

Lista paginada de pagos ya conciliados (recibo_id IS NOT NULL) de la sucursal activa. Mismo shape de respuesta que /pendientes pero con recibo_id poblado.

POST /pagos/{paymentId}/conciliar

Concilia un pago aprobado. Body vacío — el nrocomp se genera del lado del servidor desde mulcta (numerador correlativo). El service infiere sucursalId del X-Schema.

Respuesta éxito (201):

CampoTipoNotas
data.recibo_idintID del nuevo movimiento en ordcta
data.nrocompstringNúmero correlativo, padded a 6 dígitos
data.id_tipo4Constante ID_TIPO_RECIBO (Recibo CtaCte)

Mapeo de excepciones a HTTP:

Excepción de dominioHTTPerror codeBody extra
PaymentNotFoundException404PAYMENT_NOT_FOUND
PaymentNotApprovedException422PAYMENT_NOT_APPROVED{ status: <estado actual> }
OrdconNotFoundException422ORDCON_NOT_FOUND
AlreadyReconciledException409ALREADY_RECONCILED{ recibo_id: <recibo existente> }

Backend — PortalReciboCreatorService

Archivo: Modules/Portal/Application/Reconciliacion/Services/PortalReciboCreatorService.php.

Servicio de aplicación que orquesta la conciliación atómica. Es el único punto del Portal autorizado a tocar ordcta, y lo hace exclusivamente a través del puerto CtaCteReconciliacionPort (regla de arquitectura: ServiceA → ServiceB / Port, nunca ServiceA → ModelB legacy).

Constructor (DI)

DependenciaRol
ConnectionManagerManejo de conexiones nombradas + transacciones multi-conexión
PortalPaymentRepositoryInterfaceLectura tipada (PortalPaymentData) + markReciboId()
CtaCteReconciliacionPortOperaciones sobre ordcta/recfac/mulcta
OrdconLookupInterfaceVerificar existencia de ordcon en el schema sucursal
AuditLogger (opcional)Registro de auditoría — vía trait Auditable

Método conciliar(int $paymentId, int $sucursalId): array

Replica la lógica de AbstractReciboController::insert de CtaCte legacy, pero sin cheques ni retenciones — un pago portal es siempre efectivo digital sin componentes asociados.

Pre-transacción (validaciones que pueden fallar fuera del lock):

  1. paymentRepo->findByIdForReconciliacion($paymentId) → si retorna null lanza PaymentNotFoundException.
  2. Verifica payment.status === 'approved' → si no, lanza PaymentNotApprovedException con el status actual.
  3. Verifica payment.reciboId === null → si no, lanza AlreadyReconciledException con el recibo existente.

Transacción atómica (beginTransaction('principal', 'oficial') — abarca dos conexiones):

  1. setSchemaContext("suc{NNNN}") — calcula sprintf('suc%04d', $sucursalId).
  2. OrdconLookupInterface::existsByCnro($payment->ordconId) → si false, lanza OrdconNotFoundException y rollback.
  3. CtaCtePort::getNumeradorRecibo(ID_TIPO_RECIBO=4) → toma nrobol+1 con SELECT … FOR UPDATE sobre mulcta. Acá se genera el nrocomp — no viene del request.
  4. Construye MovimientoCuentaCorriente con: cliente=ordconId, concepto='Recibo Portal', numero=nrocomp, fecha=hoy, tipo_comprobante=4, haber=monto, entrega=monto, pago=hoy, schema_origen=suc{NNNN}. Llama CtaCtePort::insertMovCuenta($mov)reciboId.
  5. Itera el array decodificado de payment->facturasJson (cada elemento mapeado a FacturaRecibo vía fromArray() — soporta tanto id como id_comprobante):
    • CtaCtePort::insertReciboComprobante($reciboId, $factura) → INSERT en recfac
    • CtaCtePort::updateComprobante($factura->idComprobante, $factura->pago, $factura->saldo) → UPDATE de pago/saldo en ordcta
  6. paymentRepo->markReciboId($paymentId, $reciboId) → UPDATE con guard WHERE recibo_id IS NULL. Si retorna false lanza AlreadyReconciledException('race-condition') y rollback.
  7. registrarAuditoria('INSERT', 'portal.reconciliacion', 'portal_payments', (string)$paymentId) (vía trait Auditable, AuditLogger).
  8. commit() → ambas conexiones se commitean atómicamente.

Cualquier Throwable durante la transacciónrollBack() y rethrow.

Retorna: ['recibo_id' => int, 'nrocomp' => str_pad((string)$nrocomp, 6, '0', STR_PAD_LEFT)].

Tablas involucradas

TablaSchemaOperación
portal_paymentsEMPRESA/publicUPDATE recibo_id (vía oficial)
mulctaSUCURSALSELECT FOR UPDATE + UPDATE nrobol
ordctaSUCURSALINSERT recibo + UPDATE comprobantes
recfacSUCURSALINSERT relación recibo-comprobante
ordconSUCURSALSELECT (verificación)
audit logSUCURSALINSERT registro de auditoría

Idempotencia y race condition

Existen dos guardas independientes contra doble conciliación:

  1. Pre-transacción: payment->reciboId !== null — evita el caso optimista (el operador clickea sobre una fila ya conciliada por otro operador hace minutos).
  2. Dentro de la transacción: markReciboId(paymentId, reciboId) ejecuta UPDATE portal_payments SET recibo_id = :rid WHERE id = :pid AND recibo_id IS NULL. Si dos operadores concilian el mismo pago simultáneamente, ambas transacciones llegan al paso 6 con un reciboId recién creado. La primera que llegue al UPDATE actualiza 1 fila y commitea; la segunda actualiza 0 filas, markReciboId retorna false, se lanza AlreadyReconciledException('race-condition') y se hace rollback — el recibo y los recfac creados por el perdedor también se rollbackean porque están en la misma transacción principal.

El resultado neto es: a lo sumo un solo recibo en ordcta por portal_payments.id, sin importar la concurrencia.


Backend — PortalErpQueryService

Archivo: Modules/Portal/Application/Reconciliacion/Services/PortalErpQueryService.php.

Servicio de consulta read-only. Su existencia obedece a la regla de capas Controller → Service → Repository (el controller nunca habla con el repo directamente).

MétodoDelega en
listPendientes(sucursalId, page, limit): arraypaymentRepo->listApprovedPendingRecibo(...)
listHistorial(sucursalId, page, limit): arraypaymentRepo->listHistorial(...)

Ambos retornan el shape { data, total, page, limit } paginado.


Backend — CtaCteReconciliacionPort y CtaCteReconciliacionAdapter

Puerto (interfaz): Modules/Portal/Application/Reconciliacion/Ports/CtaCteReconciliacionPort.php.

Define el contrato outbound del Portal hacia CtaCte. El servicio de aplicación depende solo de esta interfaz, lo que permite mockear el adapter en tests unitarios sin levantar la BD.

MétodoPropósito
getNumeradorRecibo(int $idTipo): intLee y avanza nrobol en mulcta con SELECT FOR UPDATE (numerador seguro)
insertMovCuenta(MovimientoCuentaCorriente $mov): intINSERT en ordcta, retorna recibo_id
insertReciboComprobante(int $reciboId, FacturaRecibo $factura): voidINSERT en recfac (relación recibo↔comprobante)
updateComprobante(string $comprobanteId, float $pago, float $saldo): voidUPDATE de pago/saldo sobre la fila del comprobante en ordcta

Adapter (implementación de infraestructura): Modules/Portal/Infrastructure/Adapters/CtaCteReconciliacionAdapter.php.

Traduce las llamadas del puerto a los modelos legacy de CtaCte (App\models\CtaCte\…):

Método del puertoModelo legacy invocado
getNumeradorReciboTipoCuentaCorriente::getNumeradorRecibo()
insertMovCuentaCuentaCorriente::insertMovCuenta() — extrae id del array result
insertReciboComprobanteReciboComprobante::insert([id_recibo, id_comprobante, saldo, entrega])
updateComprobanteCuentaCorriente::update($comprobanteId, [pago, saldo])

La instanciación de los modelos legacy (con la PDO correcta y la tabla resuelta dinámicamente) se hace en el contenedor DI (portal-module.php), no en el adapter.

DTO FacturaRecibo

Archivo: Modules/Portal/Application/Reconciliacion/DTOs/FacturaRecibo.php.

Representa cada elemento del array facturas_json de un pago portal:

CampoTipoNotas
idComprobantestringacepta id o id_comprobante en fromArray()
pagofloatparte del monto aplicada a este comprobante
saldofloatsaldo remanente del comprobante después del pago
entregafloatdefault = pago si no viene en el payload

DTO PortalPaymentData

Archivo: Modules/Portal/Application/Reconciliacion/DTOs/PortalPaymentData.php. DTO tipado retornado por findByIdForReconciliacion() — elimina los raw arrays inter-layer.


Frontend — PagosPortalView

Archivo: bautista-app/ts/ctacte/PagosPortal/views/PagosPortalView.tsx.

Vista raíz montada sobre el contenedor PHP #pagos-portal-app desde bautista-app/ts/ctacte/PagosPortal/index.ts (entry point Vite separado).

Estructura de componentes

ComponenteArchivoRol
PagosPortalViewviews/PagosPortalView.tsxContenedor: tabs + render condicional + listener modeChanged
PagosPendientesTablecomponents/PagosPendientesTable.tsxTabla de pagos pendientes con botón "Conciliar" por fila
PagosHistorialTablecomponents/PagosHistorialTable.tsxTabla de pagos ya conciliados (read-only, columna recibo_id visible)
ConciliarDialogcomponents/ConciliarDialog.tsxDialog de confirmación + mutation conciliar

Tabs

Estado local activeTab: 'pendientes' | 'historial' controla qué tabla se renderiza. Solo una tabla viva a la vez (render condicional, no display:none) — esto evita que ambas queries de useQuery corran en paralelo cuando solo una está visible.

Listener modeChanged

useEffect se suscribe al evento global window.modeChanged y dispara queryClient.invalidateQueries({ queryKey: ['pagosPortal'] }). Cuando el usuario cambia entre modo prueba y oficial vía el ModeChanger del sidebar, ambas tablas (pendientes + historial) se refrescan automáticamente.

Flujo de conciliación

  1. Operador hace click en botón "Conciliar" en una fila de PagosPendientesTable.
  2. onConciliarClick levanta el payment al state de PagosPortalView (selectedPayment).
  3. ConciliarDialog abre con open=true (controlado por selectedPayment !== null).
  4. Operador revisa cliente/monto/gateway/fecha y clickea "Confirmar Conciliación".
  5. useMutation invoca pagosPortalService.conciliar(paymentId).
  6. onSuccess: invalida ['pagosPortal'] (refresca ambas tablas), muestra toast Recibo {id} creado (Nro. {nrocomp}), cierra el dialog.
  7. onError: muestra Alert dentro del dialog con mensaje mapeado por mapErrorMessage(). Invalida ['pagosPortal', 'pendientes'] para reflejar el estado actual del backend (puede haber sido conciliado por otro). El dialog no se cierra — el operador ve el error y decide.

Mapeo de errores HTTP → mensajes UI

ConciliarDialog::mapErrorMessage():

HTTPerror codeMensaje al operador
409ALREADY_RECONCILEDEste pago ya fue conciliado (recibo {recibo_id})
422ORDCON_NOT_FOUNDCliente asociado no encontrado en la sucursal
422otroEl pago no está en estado aprobado
otroError al conciliar el pago. Intente nuevamente.

Tablas — material-react-table

Ambas tablas usan material-react-table con configuración compartida: enablePagination: false (la paginación se maneja del lado servidor en una iteración futura — actualmente página fija page=1, limit=20), enableColumnActions: false, enableTopToolbar: false. Estados loading/error/empty manejados explícitamente con CircularProgress, Alert con botón "Reintentar" (refetch), y renderEmptyRowsFallback.

Servicio HTTP

Archivo: bautista-app/ts/ctacte/PagosPortal/services/pagosPortal.service.ts.

MétodoPathVerbo
listPendientes(page, limit)/backend/portal-erp/pagos/pendientesGET
listHistorial(page, limit)/backend/portal-erp/pagos/historialGET
conciliar(paymentId)/backend/portal-erp/pagos/{id}/conciliarPOST

Usa el cliente axios compartido (ts/api/api.ts) — el header X-Schema lo inyecta automáticamente el interceptor del cliente axios; el servicio no lo setea explícitamente.

Tipos y validación de respuestas

Archivo: bautista-app/ts/ctacte/PagosPortal/types/index.ts.

Schemas Zod (PortalPaymentSchema, ConciliarResponseSchema, PaginatedResponseSchema) declarados pero no aplicados runtime en el service actual — los .then(r => r.data.data) confían en el shape del backend. Los schemas existen como fuente de verdad para los tipos TS inferidos vía z.infer.


Tests

Backend — Unit (mocks completos, sin BD)

bautista-backend/Tests/Unit/Portal/Reconciliacion/PortalReciboCreatorServiceTest.php. Mockea ConnectionManager, PortalPaymentRepositoryInterface, CtaCteReconciliacionPort, OrdconLookupInterface y AuditLogger. Cubre:

TestCaso cubierto
testConciliar_happyPath_retornaReciboIdYNrocompFlujo completo con 2 facturas, valida 7 pasos + nrocomp padded '000101'
testConciliar_paymentNotFound_throwsExceptionRepo retorna nullPaymentNotFoundException
testConciliar_paymentNotApproved_throwsExceptionstatus 'pending'PaymentNotApprovedException
testConciliar_paymentRejected_throwsPaymentNotApprovedExceptionstatus 'rejected'PaymentNotApprovedException
testConciliar_alreadyReconciled_throwsExceptionConReciboIdExistentePre-check con recibo_id != nullAlreadyReconciledException
testConciliar_ordconNotFound_throwsExceptionexistsByCnro = false dentro de la txn → exception + rollback
testConciliar_raceCondition_markReciboIdFalse_hacerRollbackYThrowmarkReciboId retorna falseAlreadyReconciledException('race-condition') + rollback + nunca commit

Frontend — Vitest + React Testing Library

bautista-app/ts/ctacte/PagosPortal/components/ConciliarDialog.test.tsx. Mockea pagosPortalService y showToast. Provee QueryClientProvider con retry: false. Cubre:

TestCaso
no renderiza nada cuando open=falseGuard open=false → null
no renderiza nada cuando payment=nullGuard payment=null → null
muestra datos del pago cuando está abiertoRender correcto de cliente / gateway
llama onClose al clickear CancelarBotón cancelar dispara prop onClose
en onSuccess muestra toast con recibo_id y nrocomp, luego llama onCloseMutation OK → toast + close
en onError muestra mensaje de error sin llamar toast de éxitoError 409 → muestra mensaje "ya fue conciliado", no cierra

Ver también


⚠️ NOTA IMPORTANTE: Documentación retrospectiva generada a partir del código implementado. Validar con stakeholders antes de considerarla final.