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 aplaceOrdercon este medio, y el proveedor respondependingal 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_idnuevo, 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_informationniextended_response_description(src/lib/payment-attempt/confirmation-policy.ts), porquepayment.dataypayment_session.datallegan al navegador por la API store. Se conservanticket_number,response_code(uso interno) yrisk_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
successsin 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 comopaid_without_ordery 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).