Saltar a contenido

RF-011 — Pago con tarjeta por Bancard vPOS (iframe)

Estado Implementado en la rama feature/bancard-vpos (historia #149); pruebas en el ambiente de staging de Bancard superadas; pendiente la certificación y el despliegue
Tipo Payment provider propio + workflows de intento de pago + rutas API + webhook + job programado
Ubicación src/modules/bancard-vpos/ · src/workflows/bancard-vpos/ · src/lib/payment-attempt/ · src/workflows/payment-attempt/ · src/api/store/bancard-vpos/ · src/api/webhooks/bancard-vpos/ · src/jobs/bancard-vpos-reconcile.ts
Depende de Módulo Payment de Medusa — RF-000 · aviso en vivo — RF-004 · ADR-0006
Ver también Integración Bancard vPOS · Modelo de intentos de pago · RF-010 Intentos de pago en el admin · Análisis

Requisito

Cobrar con tarjeta de crédito o débito dentro del checkout, sin que el cliente salga del sitio y sin que los datos de la tarjeta pasen por nuestros servidores: el formulario lo dibuja Bancard en un iframe. El pedido se crea recién cuando Bancard confirma el cobro, y todo pago que no termine en pedido se revierte liberando el stock reservado.

Solución adoptada

Proveedor de pago bancard_vpos sobre el modelo común de intentos de pago (ADR-0006), el mismo que usa el QR. La sesión de pago nace vacía; al confirmar el paso de revisión el storefront abre el intento (process): el backend valida el carrito, reserva el stock, bloquea el carrito, pide el process_id a Bancard (single_buy) y lo devuelve. Con ese process_id el script de Bancard monta el iframe en el navegador del cliente. Cuando el cobro se resuelve, Bancard llama a la URL de confirmación; el webhook verifica el token md5 contra el monto guardado y ejecuta confirmPaymentAttemptWorkflow (processPaymentWorkflow → captura → completeCart), que crea el pedido con el pago ya capturado. Si la confirmación no llega, la conciliación consulta a Bancard y cierra el intento en un sentido o en el otro.

sequenceDiagram
    participant SF as Storefront
    participant API as Backend
    participant BAN as Bancard
    SF->>API: Elegir "Tarjeta (Bancard)" · initiatePayment
    API-->>SF: Sesión pendiente, sin process_id
    SF->>API: POST /store/bancard-vpos/process {cart_id}
    API->>API: createPaymentAttempt (valida, reserva stock, bloquea carrito)
    API->>BAN: single_buy (shop_process_id, monto texto, PYG, return_url)
    BAN-->>API: process_id
    API-->>SF: process_id y vencimiento
    SF->>BAN: El script monta el iframe con el process_id
    Note over SF,BAN: El cliente carga la tarjeta en el formulario de Bancard
    BAN->>API: POST /webhooks/bancard-vpos (token md5)
    API-->>BAN: {status: success} (antes de 30 s)
    API->>API: confirmPaymentAttempt → processPayment → completeCart → pedido
    API-->>SF: Evento SSE captured {order_id} (+ sondeo de respaldo)
    SF->>SF: cierra el checkout y muestra el pedido

Rutas y disparadores

Superficie Método Función
/store/bancard-vpos/process POST {cart_id} Abrir el pago: reserva, bloqueo y process_id de Bancard. Reutiliza el intento pendiente si ya lo hay
/store/bancard-vpos/sessions/:id/status GET ?cart_id Estado del intento (respaldo del SSE), con el motivo del rechazo y el consejo para el cliente; sin datos internos
/store/bancard-vpos/sessions/:id/cancel POST {cart_id} Cancelar el pago: reversión en Bancard, stock liberado, carrito desbloqueado
/store/bancard-vpos/simulator/confirm POST Solo con BANCARD_VPOS_SIMULATOR=true (404 si no): hace lo que haría el cliente en el iframe
/webhooks/bancard-vpos POST URL de confirmación cargada en el portal de comercios; crea el pedido
Job bancard-vpos-reconcile cada 2 min Consulta a Bancard los intentos sin confirmación más viejos que BANCARD_VPOS_CONFIRMATION_TIMEOUT_MIN y los cierra

Reglas de negocio

  • El pedido se crea solo con la confirmación validada por token md5 (clave privada + shop_process_id + confirm + monto guardado + moneda). Lo que informa el navegador es solo para la interfaz: el storefront nunca llama a placeOrder con este medio, y el proveedor responde pending al autorizar mientras el intento no esté confirmado.
  • Mientras hay un intento en curso el carrito está bloqueado (payment_attempt_in_progress) y el stock reservado a nombre de sus ítems. Confirmar, rechazar, vencer o cancelar libera ambos. Editar cualquier paso del checkout cancela el pago y desbloquea el carrito.
  • Tarjeta rechazada: el intento se cierra y se archiva, y el cliente puede reintentar sobre el mismo carrito con un intento nuevo (shop_process_id nuevo, número de intento +1).
  • Al cliente se le muestra el motivo del catálogo de la red ("Fondos insuficientes") y qué puede hacer, nunca el código de respuesta, el número de autorización ni la respuesta extendida: el manual 1.23 lo prohíbe en la página de resultado.
  • No se persisten token, authorization_number, security_information ni extended_response_description (src/lib/payment-attempt/confirmation-policy.ts), porque payment.data y payment_session.data llegan al navegador por la API store. Se conservan ticket_number, response_code (uso interno) y risk_index.
  • El webhook responde HTTP 200 siempre y antes de crear el pedido (Bancard corta a los 30 s y no reintenta). Confirmaciones duplicadas, intentos ya cerrados y el monitoreo (POST vacío cada 5 min) se responden success sin volver a procesar.
  • Si el carrito cambió entre la apertura del pago y la confirmación, la huella (cart_fingerprint) no coincide: el pago queda como paid_without_order y se avisa, en lugar de crear un pedido distinto del que el cliente pagó.
  • Reembolso desde el admin: Bancard solo admite reversión por el total y el mismo día, antes de que la transacción esté cuponada. Un reembolso parcial se rechaza sin llamar a Bancard; si Bancard no revierte, no queda un reembolso registrado y se informa el motivo para gestionarlo por el portal de comercios.

Estados del intento

Los del modelo común (src/lib/payment-attempt/types.ts): created → pending → confirmed (pedido) / rejected / expired / rolled_back / failed / paid_without_order / refund_pending. Ver Modelo de intentos de pago.

Configuración

Variable Uso
BANCARD_VPOS_PUBLIC_KEY Clave pública de la aplicación; viaja en el cuerpo de cada pedido a Bancard
BANCARD_VPOS_PRIVATE_KEY Clave privada; solo para calcular y verificar tokens md5, nunca sale del backend
BANCARD_VPOS_API_URL Base de la API; staging https://vpos.infonet.com.py:8888, producción https://vpos.infonet.com.py
BANCARD_VPOS_RETURN_URL_BASE URL completa de retorno del storefront; Bancard manda ahí al cliente al terminar
BANCARD_VPOS_CONFIRMATION_TIMEOUT_MIN Minutos sin confirmación antes de conciliar (por defecto 10)
BANCARD_VPOS_SIMULATOR true solo en desarrollo: no sale nada hacia Bancard; el arranque falla en producción

En el storefront: NEXT_PUBLIC_BANCARD_VPOS_ORIGIN (origen permitido en la CSP y elección del script -sandbox) y NEXT_PUBLIC_BANCARD_VPOS_SIMULATOR.

Criterios de aceptación

# Criterio Prueba
1 Abrir el intento reserva stock, pide single_buy con el cuerpo del manual y deja el intento pendiente; una segunda apertura devuelve el mismo integration-tests/http/bancard-vpos.spec.ts "#156 abrir el intento"
2 Si Bancard rechaza la apertura, el intento queda fallido con la clave del error y el stock liberado ídem
3 Con intento en curso: modificar el carrito, cambiar de medio y completar desde el navegador se rechazan ídem "#262 bloqueo del carrito"
4 Estado y cancelación exigen el carrito dueño; cancelar revierte en Bancard, libera stock, desbloquea y no se repite ídem "#158 estado y cancelación"
5 Confirmación aprobada → pedido con el pago capturado por el monto exacto, respuesta 200 inmediata, duplicada ignorada ídem "#157 webhook"
6 Token inválido o monto distinto del guardado → status: error y sin pedido ídem
7 Rechazo → intento rechazado, stock liberado, carrito modificable, y el cliente ve el motivo del catálogo y el consejo, sin código ni respuesta extendida ídem
8 Conciliación: pendiente reciente intacto; vencido consultado en Bancard y cerrado (pedido si hubo pago, reversión si no) bancard-vpos-reconcile.spec.ts
9 Reembolso por el total revierte y queda registrado; parcial rechazado sin llamar a Bancard; si Bancard no revierte, se informa el motivo ídem "reembolso desde el admin"
10 La ruta del simulador no existe si BANCARD_VPOS_SIMULATOR no está activo ídem "#271 simulador apagado"

Limitaciones conocidas

  • Bancard no reintenta la confirmación: si el backend está caído en ese momento, el cierre queda en manos de la conciliación (hasta 2 minutos de demora más el tiempo de espera).
  • La reversión solo es posible el mismo día y por el total. Pasado eso, la devolución se gestiona por el portal de comercios de Bancard.
  • El aviso en vivo (SSE en memoria) no funciona con réplicas del backend; el sondeo del estado es el respaldo obligatorio.
  • El catastro de tarjetas y el pago con tarjeta guardada son una historia aparte (#276), en la rama feature/bancard-vpos-catastro.
  • Pendiente: certificación de Bancard y puesta en producción (historia #306).