Skip to content

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 statusPaymentStatusNotas
pendingPENDING
issued / in_processISSUED
approvedAPPROVED
rejectedREJECTED
refundedREFUNDED
cancelledCANCELLED
deferredDEFERRED
objectedOBJECTED
reviewREVIEW
validateVALIDATE
charged_backREJECTEDContracargo → rechazado
expiredCANCELLEDExpirado → cancelado
in_mediation / mediationREVIEWMediación → en revisión
cualquier otroLanza 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ónHTTPComportamiento
InvalidPaymentReferenceException400Payload malformado — no reintentar
PaymentNotFoundException404Pago no existe — no reintentar
UnknownGatewayStatusException200Log CRITICAL + INSERT en errorbri — no reintentar
\PDOException503BD transitoriamente no disponible — el gateway reintentará
\Throwable (catch-all)200Errores 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: strtoupper en 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 + commit

devolver() — 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 + commit

La 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()

PasoOperaciónPuerto/Adapter
1setSearchPath(sucursalSchema)ConnectionManager
2getRecfacByReciboId(reciboId)CtaCteReconciliacionPort
3Guard idempotencia: recfac vacío → return
4deleteReciboComprobantes(reciboId) — DELETE recfacCtaCteReconciliacionPort
5Por cada recfac row: restoreComprobante(idComprobante, schema, saldo) — UPDATE ordctaCtaCteReconciliacionPort
6deleteMovCuenta(reciboId, 'oficial') — DELETE ordcta headerCtaCteReconciliacionPort
7Leer recibo_movimi {id, nrocaj} desde gateway_response JSONBPortalPaymentRepository::findByIdForReversal()
8isCajaAbierta(nrocaj, cajaSchema)CtaCteReconciliacionPort
9aCaja abierta → deleteMovimientoCaja(id)CtaCteReconciliacionPort
9bCaja 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:

  1. setSchemaContext("suc{NNNN}") — resuelto desde sucursal_id del pago
  2. getNumeradorRecibo(ID_TIPO_RECIBO=4)SELECT FOR UPDATE sobre mulcta
  3. insertMovCuenta($mov) — INSERT en ordcta, retorna reciboId
  4. Por cada factura en facturas_json:
    • insertReciboComprobante($reciboId, $factura) — INSERT en recfac
    • updateComprobante($factura->idComprobante, $pago, $saldo) — UPDATE en ordcta
  5. Validar caja_schema via SchemaName::fromString($cajaSchema) — lanza DomainException si el schema no coincide con el formato ^suc\d{4}(caja\d{3})?$. Ver SchemaName VO.
  6. insertMovimientoCaja($cajaSchema, $cuentaBancaria, $reciboId, $monto) — INSERT en movimi dentro de un bloque try/finally que restaura el search_path original al terminar, independientemente de si el INSERT falla o no
  7. markReciboId($paymentId, $reciboId) — UPDATE portal_payments con guard WHERE recibo_id IS NULL
  8. markReciboAt($paymentId, now()) — timestamp de conciliación exitosa
  9. commit() — 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)

AspectoPortalReciboCreatorService (eliminado)AutoReconciliacionService (actual)
TriggerHTTP POST del operadorPost-webhook (automático)
caja_idOpcionalObligatorio (portal.recibo.caja_schema)
markReciboAtNo existíaSí — persiste timestamp de conciliación
recibo_errorNo existíaSí — 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):

ColumnaTipoConstraintSignificado
recibo_atTIMESTAMPTZNULLTimestamp de conciliación automática exitosa
recibo_errorTEXTNULLMensaje 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()):

ClaveTipoDefaultDescripción
portal.recibo.cuenta_bancariastringnullNúmero de cuenta bancaria contable para movimi
portal.recibo.caja_schemastringnullSchema 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/*)
  • PortalErpQueryService
  • PortalErpRoutes
  • Tests/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

SuiteTestsStatus
AutoReconciliacionService (unit)Caso feliz, TX2 falla, config incompleta✅ PASS
CtaCteReconciliacionAdapter (unit)insertMovimientoCaja✅ PASS
PagoTicFlowIntegrationTestFlujo webhook → TX1 → TX2✅ PASS
Total portal module464 tests (pre-existentes)✅ 0 new failures

Frontend portal-usuarios

SuiteTestsStatus
useGatewayConfig3 tests (200, 403, degradación graceful)✅ PASS
PagarView guardsBotones deshabilitados si recibo_configured=false✅ PASS
Total276 tests✅ ALL PASS

Ver también


⚠️ NOTA IMPORTANTE: Validar con stakeholders antes de considerar final.