Appearance
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 onlineordcta,recfac,mulcta,ordcon(SUCURSALsuc{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étodo | Path | Controller method | Status OK | Body request |
|---|---|---|---|---|
| GET | /pagos/pendientes | listPendientes() | 200 | — |
| GET | /pagos/historial | listHistorial() | 200 | — |
| POST | /pagos/{paymentId:[0-9]+}/conciliar | conciliar() | 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 porAuthMiddleware(clave RSA pública del ERP). No es el mismo JWT que usa el Portal de Clientes. - Schema sucursal: header
X-Schema: suc0001(osuc0002, etc.) — extraído porPortalErpController::extractSucursalId(). Si elpayloadJWT trae unschemadistinto de'public', gana sobre el header. Si no hay schema válido, el controller responde400 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):
| Campo | Tipo |
|---|---|
success | true |
data.data[] | array de pagos con id, ordcon_id, sucursal_id, cliente_nombre, monto, gateway_payment_id, reference, approved_at, recibo_id |
data.total | total filas que cumplen el filtro |
data.page / data.limit | pará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):
| Campo | Tipo | Notas |
|---|---|---|
data.recibo_id | int | ID del nuevo movimiento en ordcta |
data.nrocomp | string | Número correlativo, padded a 6 dígitos |
data.id_tipo | 4 | Constante ID_TIPO_RECIBO (Recibo CtaCte) |
Mapeo de excepciones a HTTP:
| Excepción de dominio | HTTP | error code | Body extra |
|---|---|---|---|
PaymentNotFoundException | 404 | PAYMENT_NOT_FOUND | — |
PaymentNotApprovedException | 422 | PAYMENT_NOT_APPROVED | { status: <estado actual> } |
OrdconNotFoundException | 422 | ORDCON_NOT_FOUND | — |
AlreadyReconciledException | 409 | ALREADY_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)
| Dependencia | Rol |
|---|---|
ConnectionManager | Manejo de conexiones nombradas + transacciones multi-conexión |
PortalPaymentRepositoryInterface | Lectura tipada (PortalPaymentData) + markReciboId() |
CtaCteReconciliacionPort | Operaciones sobre ordcta/recfac/mulcta |
OrdconLookupInterface | Verificar 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):
paymentRepo->findByIdForReconciliacion($paymentId)→ si retornanulllanzaPaymentNotFoundException.- Verifica
payment.status === 'approved'→ si no, lanzaPaymentNotApprovedExceptioncon el status actual. - Verifica
payment.reciboId === null→ si no, lanzaAlreadyReconciledExceptioncon el recibo existente.
Transacción atómica (beginTransaction('principal', 'oficial') — abarca dos conexiones):
setSchemaContext("suc{NNNN}")— calculasprintf('suc%04d', $sucursalId).OrdconLookupInterface::existsByCnro($payment->ordconId)→ sifalse, lanzaOrdconNotFoundExceptiony rollback.CtaCtePort::getNumeradorRecibo(ID_TIPO_RECIBO=4)→ tomanrobol+1conSELECT … FOR UPDATEsobremulcta. Acá se genera elnrocomp— no viene del request.- Construye
MovimientoCuentaCorrientecon:cliente=ordconId,concepto='Recibo Portal',numero=nrocomp,fecha=hoy,tipo_comprobante=4,haber=monto,entrega=monto,pago=hoy,schema_origen=suc{NNNN}. LlamaCtaCtePort::insertMovCuenta($mov)→reciboId. - Itera el array decodificado de
payment->facturasJson(cada elemento mapeado aFacturaRecibovíafromArray()— soporta tantoidcomoid_comprobante):CtaCtePort::insertReciboComprobante($reciboId, $factura)→ INSERT enrecfacCtaCtePort::updateComprobante($factura->idComprobante, $factura->pago, $factura->saldo)→ UPDATE depago/saldoenordcta
paymentRepo->markReciboId($paymentId, $reciboId)→ UPDATE con guardWHERE recibo_id IS NULL. Si retornafalselanzaAlreadyReconciledException('race-condition')y rollback.registrarAuditoria('INSERT', 'portal.reconciliacion', 'portal_payments', (string)$paymentId)(vía traitAuditable, AuditLogger).commit()→ ambas conexiones se commitean atómicamente.
Cualquier Throwable durante la transacción → rollBack() y rethrow.
Retorna: ['recibo_id' => int, 'nrocomp' => str_pad((string)$nrocomp, 6, '0', STR_PAD_LEFT)].
Tablas involucradas
| Tabla | Schema | Operación |
|---|---|---|
portal_payments | EMPRESA/public | UPDATE recibo_id (vía oficial) |
mulcta | SUCURSAL | SELECT FOR UPDATE + UPDATE nrobol |
ordcta | SUCURSAL | INSERT recibo + UPDATE comprobantes |
recfac | SUCURSAL | INSERT relación recibo-comprobante |
ordcon | SUCURSAL | SELECT (verificación) |
| audit log | SUCURSAL | INSERT registro de auditoría |
Idempotencia y race condition
Existen dos guardas independientes contra doble conciliación:
- Pre-transacción:
payment->reciboId !== null— evita el caso optimista (el operador clickea sobre una fila ya conciliada por otro operador hace minutos). - Dentro de la transacción:
markReciboId(paymentId, reciboId)ejecutaUPDATE 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 unreciboIdrecién creado. La primera que llegue al UPDATE actualiza 1 fila y commitea; la segunda actualiza 0 filas,markReciboIdretornafalse, se lanzaAlreadyReconciledException('race-condition')y se hace rollback — el recibo y losrecfaccreados por el perdedor también se rollbackean porque están en la misma transacciónprincipal.
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étodo | Delega en |
|---|---|
listPendientes(sucursalId, page, limit): array | paymentRepo->listApprovedPendingRecibo(...) |
listHistorial(sucursalId, page, limit): array | paymentRepo->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étodo | Propósito |
|---|---|
getNumeradorRecibo(int $idTipo): int | Lee y avanza nrobol en mulcta con SELECT FOR UPDATE (numerador seguro) |
insertMovCuenta(MovimientoCuentaCorriente $mov): int | INSERT en ordcta, retorna recibo_id |
insertReciboComprobante(int $reciboId, FacturaRecibo $factura): void | INSERT en recfac (relación recibo↔comprobante) |
updateComprobante(string $comprobanteId, float $pago, float $saldo): void | UPDATE 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 puerto | Modelo legacy invocado |
|---|---|
getNumeradorRecibo | TipoCuentaCorriente::getNumeradorRecibo() |
insertMovCuenta | CuentaCorriente::insertMovCuenta() — extrae id del array result |
insertReciboComprobante | ReciboComprobante::insert([id_recibo, id_comprobante, saldo, entrega]) |
updateComprobante | CuentaCorriente::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:
| Campo | Tipo | Notas |
|---|---|---|
idComprobante | string | acepta id o id_comprobante en fromArray() |
pago | float | parte del monto aplicada a este comprobante |
saldo | float | saldo remanente del comprobante después del pago |
entrega | float | default = 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
| Componente | Archivo | Rol |
|---|---|---|
PagosPortalView | views/PagosPortalView.tsx | Contenedor: tabs + render condicional + listener modeChanged |
PagosPendientesTable | components/PagosPendientesTable.tsx | Tabla de pagos pendientes con botón "Conciliar" por fila |
PagosHistorialTable | components/PagosHistorialTable.tsx | Tabla de pagos ya conciliados (read-only, columna recibo_id visible) |
ConciliarDialog | components/ConciliarDialog.tsx | Dialog 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
- Operador hace click en botón "Conciliar" en una fila de
PagosPendientesTable. onConciliarClicklevanta el payment al state dePagosPortalView(selectedPayment).ConciliarDialogabre conopen=true(controlado porselectedPayment !== null).- Operador revisa cliente/monto/gateway/fecha y clickea "Confirmar Conciliación".
useMutationinvocapagosPortalService.conciliar(paymentId).onSuccess: invalida['pagosPortal'](refresca ambas tablas), muestra toastRecibo {id} creado (Nro. {nrocomp}), cierra el dialog.onError: muestraAlertdentro del dialog con mensaje mapeado pormapErrorMessage(). 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():
| HTTP | error code | Mensaje al operador |
|---|---|---|
| 409 | ALREADY_RECONCILED | Este pago ya fue conciliado (recibo {recibo_id}) |
| 422 | ORDCON_NOT_FOUND | Cliente asociado no encontrado en la sucursal |
| 422 | otro | El pago no está en estado aprobado |
| otro | — | Error 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étodo | Path | Verbo |
|---|---|---|
listPendientes(page, limit) | /backend/portal-erp/pagos/pendientes | GET |
listHistorial(page, limit) | /backend/portal-erp/pagos/historial | GET |
conciliar(paymentId) | /backend/portal-erp/pagos/{id}/conciliar | POST |
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:
| Test | Caso cubierto |
|---|---|
testConciliar_happyPath_retornaReciboIdYNrocomp | Flujo completo con 2 facturas, valida 7 pasos + nrocomp padded '000101' |
testConciliar_paymentNotFound_throwsException | Repo retorna null → PaymentNotFoundException |
testConciliar_paymentNotApproved_throwsException | status 'pending' → PaymentNotApprovedException |
testConciliar_paymentRejected_throwsPaymentNotApprovedException | status 'rejected' → PaymentNotApprovedException |
testConciliar_alreadyReconciled_throwsExceptionConReciboIdExistente | Pre-check con recibo_id != null → AlreadyReconciledException |
testConciliar_ordconNotFound_throwsException | existsByCnro = false dentro de la txn → exception + rollback |
testConciliar_raceCondition_markReciboIdFalse_hacerRollbackYThrow | markReciboId retorna false → AlreadyReconciledException('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:
| Test | Caso |
|---|---|
no renderiza nada cuando open=false | Guard open=false → null |
no renderiza nada cuando payment=null | Guard payment=null → null |
muestra datos del pago cuando está abierto | Render correcto de cliente / gateway |
llama onClose al clickear Cancelar | Botón cancelar dispara prop onClose |
en onSuccess muestra toast con recibo_id y nrocomp, luego llama onClose | Mutation OK → toast + close |
en onError muestra mensaje de error sin llamar toast de éxito | Error 409 → muestra mensaje "ya fue conciliado", no cierra |
Ver también
- Reconciliación de Pagos del Portal — vista de negocio
PortalErpControllerPortalReciboCreatorServiceCtaCteReconciliacionPort/CtaCteReconciliacionAdapterPortalPaymentRepositoryInterface— sección "ERP Reconciliation methods"PagosPortalView(frontend)
⚠️ NOTA IMPORTANTE: Documentación retrospectiva generada a partir del código implementado. Validar con stakeholders antes de considerarla final.