Appearance
Conciliación Automática — Detalle Técnico
⚠️ DOCUMENTACIÓN RETROSPECTIVA — Generada a partir de código implementado el 2026-06-24
Módulo: Portal de Clientes — Backend Tipo: Technical Estado: Implementado Fecha: 2026-05-12 — Actualizado 2026-06-24 (hardening: mapeo de estados, validación de monto, lock en cancelar/devolver, reversión contable, recovery jobs)
Arquitectura
La conciliación automática se implementa como TX2 en AutoReconciliacionService, disparado desde el webhook handler post-commit de TX1. El servicio usa el puerto CtaCteReconciliacionPort (sin dependencias directas de los modelos legacy de CtaCte) y un nuevo método insertMovimientoCaja.
WebhookController
└─ PortalPaymentService::applyWebhookResult() [TX1]
└─ commit TX1
└─ AutoReconciliacionService::reconcile($payment) [TX2]
└─ CtaCteReconciliacionPort::getNumeradorRecibo()
└─ CtaCteReconciliacionPort::insertMovCuenta()
└─ CtaCteReconciliacionPort::insertReciboComprobante() × N
└─ CtaCteReconciliacionPort::updateComprobante() × N
└─ CtaCteReconciliacionPort::insertMovimientoCaja()
└─ PortalPaymentRepository::markReciboId() + markReciboAt()PagoTicAdapter — Mapeo de estados (mapStatus)
Archivo: Modules/Portal/Infrastructure/Gateway/PagoTic/PagoTicAdapter.php
El método mapStatus(string $gatewayStatus): PaymentStatus es el único punto de traducción entre los strings del gateway y el enum PaymentStatus. El mapeo es estricto:
| Gateway status | PaymentStatus | Notas |
|---|---|---|
pending | PENDING | |
issued / in_process | ISSUED | |
approved | APPROVED | |
rejected | REJECTED | |
refunded | REFUNDED | |
cancelled | CANCELLED | |
deferred | DEFERRED | |
objected | OBJECTED | |
review | REVIEW | |
validate | VALIDATE | |
charged_back | REJECTED | Contracargo → rechazado |
expired | CANCELLED | Expirado → cancelado |
in_mediation / mediation | REVIEW | Mediación → en revisión |
| cualquier otro | — | Lanza UnknownGatewayStatusException |
UnknownGatewayStatusException: extiende \DomainException. No está registrada en el handler global de Slim — solo la captura PortalWebhookController.
PortalWebhookController — Jerarquía de catches
Archivo: Modules/Portal/Presentation/Payment/Controllers/PortalWebhookController.php
El controlador implementa cinco catches tipados en orden. El orden determina qué código HTTP retorna el endpoint ante cada situación:
| Excepción | HTTP | Comportamiento |
|---|---|---|
InvalidPaymentReferenceException | 400 | Payload malformado — no reintentar |
PaymentNotFoundException | 404 | Pago no existe — no reintentar |
UnknownGatewayStatusException | 200 | Log CRITICAL + INSERT en errorbri — no reintentar |
\PDOException | 503 | BD transitoriamente no disponible — el gateway reintentará |
\Throwable (catch-all) | 200 | Errores internos — no generar storm de reintentos |
La regla general es "siempre 200 salvo que el payload sea inválido o la BD esté caída". El único caso que habilita reintento del gateway es \PDOException (503).
writeErrorbri(\Throwable $e) inserta en errorbri (tabla de la conexión ini) con layer = 'Portal-Webhook'. El fallo del propio INSERT se swallow silenciosamente para no romper la respuesta HTTP.
PortalWebhookService — Validación de monto/moneda
Archivo: Modules/Portal/Application/Payment/Services/PortalWebhookService.php
Ejecutado post-commit de TX1, pre-TX2. Método privado isAmountAndCurrencyValid():
- Monto:
abs(result.amount - lockedRow.monto) <= 0.001 - Moneda: si
result.currency === null→ skip (gateway legacy). De lo contrario:strtoupperen ambos lados.
En caso de mismatch: markReciboError(paymentId, "AMOUNT_MISMATCH: ...") y return sin lanzar excepción. TX2 no se ejecuta. El status ya es approved (TX1 commiteó).
WebhookResult expone el campo currency (nullable string) capturado desde body['currency_id'].
PortalPaymentService — Lock en cancelar y devolver
Archivo: Modules/Portal/Application/Payment/Services/PortalPaymentService.php
cancelar() — Flujo post-hardening
1. Pre-flight (sin TX): findByIdForOrdcon + canTransitionTo(CANCELLED) — fail-fast
2. Llamada HTTP al gateway (fuera de TX) — si el pago fue al gateway
3. TX 'principal':
a. lockPaymentRowForUpdate(paymentId) — SELECT FOR UPDATE
b. tryFrom(lockedRow.status) — re-validar con status DB real
c. null → commit no-op
d. !canTransitionTo(CANCELLED) → rollback + InvalidPaymentStateException
e. markCancelled() + auditoria + commitdevolver() — Flujo post-hardening
1. Pre-flight (sin TX): findByIdForOrdcon + canTransitionTo(REFUNDED)
2. Llamada HTTP al gateway (fuera de TX): refundPayment()
3. TX 'principal':
a. lockPaymentRowForUpdate(paymentId)
b. null → commit no-op
c. ya REFUNDED → commit no-op (idempotente)
d. !canTransitionTo(REFUNDED) → rollback + InvalidPaymentStateException
e. Si recibo_id != null && reversalService configurado:
cajaSchema = dataConfig.get(RECIBO_CAJA_SCHEMA)
reversalService.revertirSinTransaccion(paymentData, reciboId, cajaSchema)
f. markRefunded() + auditoria + commitLa llamada al gateway queda fuera de la TX en ambos casos, para que la TX de escritura sea lo más corta posible y el lock de fila no se mantenga durante la llamada HTTP.
PortalReciboReversalService
Archivo: Modules/Portal/Application/Reconciliacion/Services/PortalReciboReversalService.php
Servicio nuevo que revierte el ciclo contable de un pago aprobado. No abre TX propia — se ejecuta dentro de la TX de devolver.
Pasos internos de revertirSinTransaccion()
| Paso | Operación | Puerto/Adapter |
|---|---|---|
| 1 | setSearchPath(sucursalSchema) | ConnectionManager |
| 2 | getRecfacByReciboId(reciboId) | CtaCteReconciliacionPort |
| 3 | Guard idempotencia: recfac vacío → return | — |
| 4 | deleteReciboComprobantes(reciboId) — DELETE recfac | CtaCteReconciliacionPort |
| 5 | Por cada recfac row: restoreComprobante(idComprobante, schema, saldo) — UPDATE ordcta | CtaCteReconciliacionPort |
| 6 | deleteMovCuenta(reciboId, 'oficial') — DELETE ordcta header | CtaCteReconciliacionPort |
| 7 | Leer recibo_movimi {id, nrocaj} desde gateway_response JSONB | PortalPaymentRepository::findByIdForReversal() |
| 8 | isCajaAbierta(nrocaj, cajaSchema) | CtaCteReconciliacionPort |
| 9a | Caja abierta → deleteMovimientoCaja(id) | CtaCteReconciliacionPort |
| 9b | Caja cerrada (o recibo_movimi null) → insertContraMovimientoCaja(...) | CtaCteReconciliacionPort |
recibo_movimi se persiste en portal_payments.gateway_response como {"recibo_movimi": {"id": N, "nrocaj": M}} durante la ejecución de insertMovimientoCaja en TX2.
db_origen se resuelve desde el campo db_origen de cada factura en facturas_json (snapshotted en iniciar). Default: 'oficial'.
AutoReconciliacionService
Flujo de TX2
TX2 es una transacción atómica sobre conexiones principal + oficial:
setSchemaContext("suc{NNNN}")— resuelto desdesucursal_iddel pagogetNumeradorRecibo(ID_TIPO_RECIBO=4)—SELECT FOR UPDATEsobremulctainsertMovCuenta($mov)— INSERT enordcta, retornareciboId- Por cada factura en
facturas_json:insertReciboComprobante($reciboId, $factura)— INSERT enrecfacupdateComprobante($factura->idComprobante, $pago, $saldo)— UPDATE enordcta
- Validar
caja_schemaviaSchemaName::fromString($cajaSchema)— lanzaDomainExceptionsi el schema no coincide con el formato^suc\d{4}(caja\d{3})?$. Ver SchemaName VO. insertMovimientoCaja($cajaSchema, $cuentaBancaria, $reciboId, $monto)— INSERT enmovimidentro de un bloquetry/finallyque restaura elsearch_pathoriginal al terminar, independientemente de si el INSERT falla o nomarkReciboId($paymentId, $reciboId)— UPDATE portal_payments con guardWHERE recibo_id IS NULLmarkReciboAt($paymentId, now())— timestamp de conciliación exitosacommit()— ambas conexiones atómicas
Si cualquier paso falla → rollBack() + persist recibo_error en portal_payments.
Nota sobre el try/finally en insertMovimientoCaja: El adapter cambia temporalmente el search_path de la conexion para apuntar al schema de caja. El bloque try/finally garantiza que el search_path original se restaura incluso si el INSERT lanza una excepcion. Sin este patron, un fallo en el INSERT dejaria la conexion con un search_path incorrecto para los queries siguientes.
Diferencias respecto al servicio manual (eliminado)
| Aspecto | PortalReciboCreatorService (eliminado) | AutoReconciliacionService (actual) |
|---|---|---|
| Trigger | HTTP POST del operador | Post-webhook (automático) |
| caja_id | Opcional | Obligatorio (portal.recibo.caja_schema) |
| markReciboAt | No existía | Sí — persiste timestamp de conciliación |
| recibo_error | No existía | Sí — persiste mensaje de fallo de TX2 |
Extensión de CtaCteReconciliacionPort
Archivo: Modules/Portal/Application/Reconciliacion/Ports/CtaCteReconciliacionPort.php
Nuevo método agregado al puerto:
php
public function insertMovimientoCaja(
string $cajaSchema,
string $cuentaBancaria,
int $reciboId,
float $monto
): void;Implementación en el Adapter (CtaCteReconciliacionAdapter.php): Usa MovimientoCaja model directamente (Plan B ADR 6). El adapter inyecta el schema de caja desde el constructor DI — no necesita ConnectionManager::setSchemaContext porque usa una conexión ya configurada con el schema correcto.
Columnas nuevas en portal_payments
Agregadas por migración AlterPortalPaymentsAddReciboColumns (tipo BASE, nivel SUCURSAL/EMPRESA):
| Columna | Tipo | Constraint | Significado |
|---|---|---|---|
recibo_at | TIMESTAMPTZ | NULL | Timestamp de conciliación automática exitosa |
recibo_error | TEXT | NULL | Mensaje del último error de TX2 |
Ambas son nullable. Filas existentes migran con NULL en ambas columnas.
Seeds de data_config
Agregados en migrations/seeds/tenancy/DataConfig.php, dentro del bloque if ($this->isPortalClientesEnabled()):
| Clave | Tipo | Default | Descripción |
|---|---|---|---|
portal.recibo.cuenta_bancaria | string | null | Número de cuenta bancaria contable para movimi |
portal.recibo.caja_schema | string | null | Schema de la caja destino (suc0001caja001) |
Ambas claves se crean vacías. El operador las configura desde la sección Gateway del ERP.
getGatewayConfig() — respuesta extendida
El endpoint GET /backend/config/gateway ahora incluye recibo_configured:
json
{
"gateway_configured": true,
"gateway_name": "paypertic",
"recibo_configured": true
}recibo_configured = true si y solo si portal.recibo.cuenta_bancaria Y portal.recibo.caja_schema están presentes en data_config.
Prerequisito en iniciar pago
PortalPaymentService::iniciarPago() verifica recibo_configured antes de crear la preferencia en el gateway. Si recibo_configured = false:
→ lanza ReciboNotConfiguredException
→ HTTP 422 (controller lo mapea)No es posible iniciar un pago sin esta configuración completa.
Frontend portal-usuarios — Guards de botones
useGatewayConfig() expone isReciboConfigured derivado de recibo_configured:
typescript
// src/core/hooks/useGatewayConfig.ts
export interface GatewayConfigState {
isEnabled: boolean;
isReciboConfigured: boolean;
config: GatewayConfig | null;
}Los botones de iniciar pago en PagarView están deshabilitados cuando isReciboConfigured = false. Un Alert informativo le indica al usuario que los pagos online no están disponibles en este momento.
Eliminación de Phase 5
Los siguientes artefactos fueron eliminados:
Backend:
PortalErpController+ rutas (/backend/portal-erp/pagos/*)PortalErpQueryServicePortalErpRoutesTests/Integration/Portal/PortalErpControllerTest.php
Frontend (bautista-app):
- Directorio
ts/ctacte/PagosPortal/completo - Entrada en
ts/ctacte/config/sidebar.ts - Export en
ts/ctacte/index.ts
Verificación post-eliminación: grep portal-erp → 0 resultados.
Tests
Backend
| Suite | Tests | Status |
|---|---|---|
| AutoReconciliacionService (unit) | Caso feliz, TX2 falla, config incompleta | ✅ PASS |
| CtaCteReconciliacionAdapter (unit) | insertMovimientoCaja | ✅ PASS |
| PagoTicFlowIntegrationTest | Flujo webhook → TX1 → TX2 | ✅ PASS |
| Total portal module | 464 tests (pre-existentes) | ✅ 0 new failures |
Frontend portal-usuarios
| Suite | Tests | Status |
|---|---|---|
| useGatewayConfig | 3 tests (200, 403, degradación graceful) | ✅ PASS |
| PagarView guards | Botones deshabilitados si recibo_configured=false | ✅ PASS |
| Total | 276 tests | ✅ ALL PASS |
Ver también
- Conciliación Automática — Proceso de Negocio
- Jobs de Recuperación de Pagos — sweep y retry cron jobs
- Configuración de Gateway — Técnico — campo cuentaBancaria
- Schema portal_payments — columnas recibo_at/recibo_error
⚠️ NOTA IMPORTANTE: Validar con stakeholders antes de considerar final.