Ir al contenido
DocuGraph

ecommerce-angular-spring

BE-06 Webhooks Mercado Pago idempotentes

BE-06 — Webhooks Mercado Pago idempotentes

Sección titulada «BE-06 — Webhooks Mercado Pago idempotentes»
  • Depende de: BE-05, DS-03, ADR-05, ADR-08.
  • Módulo: checkout (adaptador de entrada), shared (webhook_events).
  • Estado: DONE (2026-09-21). El cierre de la compra (orden PAID) es BE-07. El despacho del outbox es BE-12.

Procesar notificaciones Webhooks de Mercado Pago de forma segura e idempotente.

  • Adaptador REST mercadoPagoWebhook (POST /webhooks/mercadopago) sin JWT.
  • Validación de origen: header x-signature (ts, v1) con HMAC-SHA256 y manifiesto id:[data.id];request-id:[x-request-id];ts:[ts]; (documentación oficial de Webhooks / Checkout Pro).
  • Tras validar, consultar el pago en la API de Mercado Pago con data.id; no se confía solo en el cuerpo.
  • Registro en webhook_events por event_id único (id de la notificación). Provider MERCADO_PAGO.
  • approved → pago SUCCEEDED + checkout.PaymentSucceeded; rejected / cancelled → checkout.PaymentFailed, vía Outbox.
  • Liberar reserva y confirmar stock al emitir esos eventos (puerto de inventory ya existente, síncrono en la misma transacción).
  • Yape no es un webhook distinto: llega como pago Checkout Pro con su payment_method_id.
  • Secreto MERCADO_PAGO_WEBHOOK_SECRET (no se versiona; dueño de no versionar YAML local: BE-11).

Criterios de aceptación (DoD de esta tarea)

Sección titulada «Criterios de aceptación (DoD de esta tarea)»
  • Evento repetido (mismo event_id) no duplica pago ni eventos.
  • Firma inválida = 400 ProblemDetail.
  • El cambio de estado del pago y el evento Outbox se escriben en la misma transacción.
  • Pasar la orden a PAID y exponer listMyOrders: BE-07 (consume checkout.PaymentSucceeded / checkout.PaymentFailed).
  • Relay que marca published_at y entrega el outbox a handlers: BE-12 (BACKLOG).
  • E2E Playwright del camino feliz: FE-12.

Integración Testcontainers: replay del mismo evento (firma válida e inválida), más 400 si falta el id de notificación.

  • test: backend/src/test/kotlin/com/qhatu/ecommerce/checkout/MercadoPagoWebhookIntegrationTest.kt
  • test: backend/src/test/kotlin/com/qhatu/ecommerce/checkout/ProcessMercadoPagoWebhookUseCaseTest.kt
  • test: backend/src/test/kotlin/com/qhatu/ecommerce/checkout/mercadopago/MercadoPagoSignatureValidatorTest.kt
  • file: backend/src/main/kotlin/com/qhatu/ecommerce/checkout/application/ProcessMercadoPagoWebhookUseCase.kt
  • file: backend/src/main/kotlin/com/qhatu/ecommerce/checkout/infrastructure/mercadopago/MercadoPagoWebhookController.kt
  • Contrato: POST /webhooks/mercadopago en contract/openapi.yaml (200, 400, 503).

Runtime local (2026-09-21): bootRun --spring.profiles.active=local en :8081, Postgres :5433, ngrok http 8081. HMAC inválido → 400; sin id → 400 incomplete-webhook-notification; pago 404 en MP → 503 sin persistir event_id (MP reintenta). Replay con id presente no duplica outbox.

Outbox aggregateType=checkout (filas con published_at NULL hasta que exista BE-12):

eventType Payload JSON Efecto ya hecho en checkout/inventory
checkout.PaymentSucceeded eventId, paymentId, orderId, amountCents, occurredAt pago SUCCEEDED; reserva CONFIRMED
checkout.PaymentFailed eventId, paymentId, orderId, reason, occurredAt pago FAILED; reserva liberada

La correlación del webhook usa external_reference (UUID de orden), data.id (payment id) o preference_id. Tras un match, payments.provider_reference pasa al payment id si no hay colisión.

Docs oficiales: Webhooks HMAC (data.id query; alfanumérico en minúsculas) y ACK HTTP 200/201 (si no, MP reintenta). SDK PHP 2026-06 firma con la capitalización original: el validador acepta ambos manifiestos.

  • CA-2: HMAC inválido → 400 invalid-webhook-signature.
  • CA-1: replay con id presente no duplica webhook_events ni outbox.
  • Notificación sin id → 400 incomplete-webhook-notification (ya no se persiste event_id=0).
  • GET /v1/payments/{id} 404 → 503 webhook-resource-unavailable sin persistir el evento.
  • Tests Testcontainers/unitarios: OK sin token MP real en el entorno de Gradle.

Empezar BE-07. Pedir get_context(mode=task, taskId=BE-07). No reabrir firma HMAC ni webhook_events. El camino vivo webhook → orden PAID también necesita BE-12 (hoy no hay relay).