Pipeline de resultado de pago (entrega 3, Q2)¶
Cómo el GatewayQR le cuenta a BeneficiosCenter qué pasó realmente con un pago que ya
resolvió — el complemento del contrato de API de resolución, que solo sabe
qué se ofreció. Fuente: ACCEPTANCE.md §Entrega 3 y el código en
backend/src/main/java/com/tipre/beneficioscenter/auditoria/ del repositorio del sistema.
Lo ofrecido vs. lo que pasó
POST /resolver responde antes de que el autorizador apruebe nada: es una oferta. Este
endpoint registra el desenlace real — aprobado o rechazado, con qué cuenta y qué descuento
efectivo — después de que el autorizador ya contestó. Son dos preguntas distintas y dos
tablas distintas: consulta (lo ofrecido) y resultado_pago (lo que pasó). Ver
Panel de resultados para el tablero que
compara ambas.
Vive bajo /api/v1/beneficios/**, la misma familia de rutas que /resolver: exige
X-Api-Key válida y responde 401 sin persistir nada si falta o es inválida (INV-S01) — no es
un endpoint nuevo con su propia seguridad, hereda la cadena de la API de resolución.
Por qué es fire-and-forget¶
El GatewayQR llama a este endpoint después de que el autorizador ya aprobó o rechazó el pago — registrar el resultado es telemetría y control, no dinero en tránsito. Pero la llamada no puede tener permiso para frenar ni fallar el pago: si BeneficiosCenter está lento o caído en ese instante, el gateway ya completó el cobro y solo pierde una fila de estadística, nunca una transacción.
Del lado del servidor la respuesta es asíncrona, con la misma forma que la auditoría de
/resolver (ADR-002): validar → encolar → responder, sin tocar la base en el hilo que atiende
al gateway.
sequenceDiagram
participant GW as GatewayQR
participant CTRL as ResultadoPagoController
participant COLA as ColaResultado<br/>(cola acotada)
participant CONS as ResultadoConsumer<br/>(virtual thread)
participant DB as SQL Server<br/>resultado_pago
Note over GW: El autorizador ya respondió —<br/>esta llamada es fire-and-forget
GW->>CTRL: POST /resultado (X-Api-Key)
CTRL->>COLA: encolar(item)
alt cola con lugar
COLA-->>CTRL: true
else cola llena
COLA-->>CTRL: false (resultados.perdidos++)
end
CTRL-->>GW: 202 Accepted {requestId, estado, encolado}
Note over GW,CTRL: El gateway sigue sin esperar nada más
loop en lotes, fuera del hilo del gateway
CONS->>COLA: drenar
CONS->>DB: upsert por requestId (idempotente, AC-48)
end
Request¶
{
"requestId": "GW-20260903-000123",
"estado": "APROBADO",
"paymentMethodId": 990,
"benefitId": 3,
"descuentoAplicado": 15000.00,
"importeOriginal": 100000.00,
"importeFinal": 85000.00
}
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
requestId |
string | sí | El mismo requestId (trxid) que se usó en /resolver — es la clave de correlación entre lo ofrecido y lo que pasó. |
estado |
APROBADO | RECHAZADO |
sí | Desenlace del pago según el autorizador. |
paymentMethodId |
long | no | Cuenta que efectivamente pagó (990, 991, …), tal como la devolvió el autorizador. |
benefitId |
long | no | Beneficio aplicado, recuperado por el gateway desde su propia intención persistida — ver benefitId: de dónde sale. null si no se pudo resolver. |
descuentoAplicado |
decimal (≥ 0) | no | Monto de descuento que terminó aplicándose. |
importeOriginal / importeFinal |
decimal (≥ 0) | no | Importe del ticket antes y después del descuento. |
motivoRechazo |
string (máx. 200) | no | Texto libre, solo tiene sentido con estado: RECHAZADO. |
Response 202¶
202, no 200: el resultado todavía no está persistido cuando esta respuesta sale — solo
significa que entró a la cola. encolado: false (cola llena, AC-50) también responde 202,
nunca un error: es seguro reintentar, porque la persistencia es idempotente por requestId
(AC-48).
Errores¶
| Status | Cuándo | field |
|---|---|---|
400 |
requestId vacío, estado nulo, o un importe negativo (Bean Validation) |
el campo que falló |
401 |
X-Api-Key ausente o inválida |
X-Api-Key |
Igual que en /resolver, el 401 nunca llega a encolar ni a evaluar nada (INV-S01).
Cómo se procesa (camino feliz)¶
- Autenticar la API key (misma cadena que
/resolver). Sin clave válida:401. - Validar el body. Campo inválido:
400con el nombre del campo (AC-49). - Encolar en
ColaResultado— unArrayBlockingQueueacotado (por defecto 10 000,bc.resultado.queue-size, ver Configuración) que nunca bloquea: si está llena, se cuenta como perdida enresultados.perdidosy se loguea, nunca se oculta (AC-50, mismo criterio queauditoria.perdidas/INV-O02). - Responder
202de inmediato conencoladoreflejando el paso anterior. - Un
ResultadoConsumeren un virtual thread dedicado drena la cola en lotes y hace upsert enresultado_pago: busca porrequestId, actualiza si ya existe, inserta si no — así un reintento del gateway con el mismorequestIdnunca duplica la fila (AC-48). - Si falla la persistencia de un lote completo (base caída), cada ítem del lote se cuenta como
perdido y el consumidor sigue vivo — nunca se cae por una falla de base de datos, igual que
AuditoriaConsumer. - Al apagar el proceso, un drenaje final con presupuesto de tiempo (5 s) persiste lo que quede en cola; lo que no llegue a tiempo se cuenta como perdido, nunca se descarta en silencio.
benefitId: de dónde sale¶
BeneficiosCenter nunca inventa este dato — lo recibe del gateway y solo lo guarda. Quien lo
calcula es el GatewayQR: cuando arma la intención de pago, guarda su propia copia de
benefits_methods_data (con el benefitId que le mandó BeneficiosCenter en /resolver) en la
transacción persistida (jsonreqintencion). Cuando el autorizador responde por webhook con
payment_method_id, el gateway busca en esa copia la entrada cuyo benefits_card.code coincide
con la cuenta que pagó, y le manda ese benefitId (como número, no string) a
POST /resultado. Es best-effort: si no hay match o la intención no tiene el campo, viaja
null y el gateway sigue igual — nunca afecta el pago. Funciona tanto contra el autorizador real
como contra el emulador local, porque no depende de que el autorizador devuelva el benefitId
(nunca lo hace) — el gateway lo reconstruye de su propio lado.
Gap conocido: todavía no hay KPI \"ofrecido vs. aplicado\"
Antes de este round-trip, benefit_id en resultado_pago era siempre null. Con el
round-trip ya en producción del lado del GatewayQR, el dato puede empezar a llegar, pero el
Panel de resultados todavía no agrupa
por beneficio — deliberadamente, para no construir un KPI sobre una columna que hasta hace
poco era siempre null. Queda anotado como próximo paso, no como algo ya construido.
Panel de resultados (consumo de este dato)¶
Mismo patrón que el Tablero: ambos roles
leen (INV-S04), rango por defecto hoy, máximo 90 días, 400 nombrando hasta si se supera
(AC-52). Un controller y un service propios (ResultadoTableroController/Service), separados
del tablero original a propósito, para no tocar esa pieza ya probada.
{
"desde": "2026-09-12",
"hasta": "2026-09-12",
"totalConResultado": 1,
"aprobados": 1,
"rechazados": 0,
"tasaAprobacionPct": 100.00,
"descuentoConsultadoTotal": 15000.00,
"descuentoEfectivoTotal": 15000.00,
"conversionPct": 100.00,
"porMedioDePago": [
{ "paymentMethodId": 990, "cantidad": 1, "porcentaje": 100.00 }
]
}
| Campo | Descripción |
|---|---|
totalConResultado |
Filas de resultado_pago en el rango. |
aprobados / rechazados |
Conteo por estado. |
tasaAprobacionPct |
aprobados / totalConResultado × 100; 0.00 si no hay datos (AC-53, sin dividir por cero). |
descuentoConsultadoTotal |
"Descuento consultado" en la consola: suma de consulta_beneficio.descuento_ofrecido (V8) en el rango — lo que el resolver cotizó al ofrecer cada beneficio, sin importar si terminó en un pago. NULL anterior a V8 se trata como cero (COALESCE). |
descuentoEfectivoTotal |
"Descuento otorgado" en la consola (antes "Descuento efectivo" — mismo campo, la comparación con lo consultado es lo nuevo): suma de descuentoAplicado solo de los APROBADO. |
conversionPct |
descuentoEfectivoTotal / descuentoConsultadoTotal × 100 (V8): qué porción de lo ofrecido terminó pagado. Por debajo de 100 % es esperable — no toda oferta se convierte en pago. 0.00 si no se consultó nada en el rango, sin dividir por cero. |
porMedioDePago[] |
Agrupado por paymentMethodId; una fila con paymentMethodId: null agrupa los resultados sin ese dato ("sin dato" en la consola) — no se descartan. |
Con la cola llena o sin resultados en el rango, la respuesta sigue siendo 200 con listas y
totales en cero — nunca un error (AC-53).
Configuración¶
| Clave | Significado |
|---|---|
bc.resultado.queue-size |
Capacidad de ColaResultado (por defecto 10 000), mismo mecanismo que bc.auditoria.queue-size. |
Dónde sigue¶
- El contrato de lo que se ofrece (antes del pago), en API de resolución.
- Cómo se ve este dato en la consola, en Consola web — Resultados de pago.
- Todos los criterios que fijan este contrato, en la Matriz de aceptación.
- Una corrida real de punta a punta que alimentó estos números, en E2E con GatewayQR.