Saltar a contenido

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.

POST /api/v1/beneficios/resultado

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 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 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

{ "requestId": "GW-20260903-000123", "estado": "APROBADO", "encolado": true }

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)

  1. Autenticar la API key (misma cadena que /resolver). Sin clave válida: 401.
  2. Validar el body. Campo inválido: 400 con el nombre del campo (AC-49).
  3. Encolar en ColaResultado — un ArrayBlockingQueue acotado (por defecto 10 000, bc.resultado.queue-size, ver Configuración) que nunca bloquea: si está llena, se cuenta como perdida en resultados.perdidos y se loguea, nunca se oculta (AC-50, mismo criterio que auditoria.perdidas/INV-O02).
  4. Responder 202 de inmediato con encolado reflejando el paso anterior.
  5. Un ResultadoConsumer en un virtual thread dedicado drena la cola en lotes y hace upsert en resultado_pago: busca por requestId, actualiza si ya existe, inserta si no — así un reintento del gateway con el mismo requestId nunca duplica la fila (AC-48).
  6. 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.
  7. 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)

GET /api/admin/tablero/resultados?desde&hasta

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