Saltar a contenido

Análisis · Pago con tarjeta por Bancard vPOS embebido (iframe) en el checkout

Qué es este documento. Investigación previa a la implementación. Reúne lo que exige Bancard para el vPOS por iframe, lo que ya tenemos en la plataforma (Bancard QR, CSP, Cloudflare) y el diseño acordado con foco en seguridad. Las decisiones de arquitectura se registran en ADR y el requisito funcional en un RF-0xx cuando se implemente.

Historial de decisiones. 2026-09-08: se decidió crear el pedido antes del pago, como el QR. 2026-09-09: se revirtió. Para los medios cuyo pago confirma un tercero (Bancard QR y vPOS) el pedido se crea después de capturar el pago; la transferencia bancaria sigue con pedido primero. Este documento refleja la decisión vigente.

1. Resumen

  • Qué queremos: que el cliente pague con tarjeta sin salir de nuestro sitio. Bancard lo resuelve con un iframe suyo (Bancard.Checkout.createForm) dentro de nuestro checkout: los datos de la tarjeta se cargan en una página de Bancard, nunca pasan por nuestros servidores.
  • Qué hace falta antes de escribir código: un alta de vPOS con Bancard (es un producto distinto del QR que ya usamos), las claves de staging (ya disponibles), cargar en el portal de comercios la URL de confirmación de cada ambiente, y superar la certificación de Bancard antes de que habiliten producción.
  • Qué ya tenemos que sirve: un proveedor de pago propio de Medusa para Bancard QR (módulo, webhook, SSE, expiración, reversión) que marca el patrón; una CSP con nonce en el storefront que hoy está en modo "solo reporta"; y la entrada por túnel de Cloudflare sin IP pública.
  • Modelo acordado (2026-09-09): el cliente paga en el paso de revisión del checkout; la confirmación de Bancard (servidor a servidor) captura el pago y crea el pedido. Mientras el pago está en curso, el stock queda reservado y el carrito bloqueado. Cada intento de pago queda en un registro de intentos común a las pasarelas, que después tendrá su vista en el admin (historia aparte). El QR migra a este mismo modelo en una tarea propia.
  • Riesgos principales: confiar en lo que dice el navegador en vez de la confirmación; validar mal el token de confirmación (monto); reutilizar shop_process_id; que Cloudflare (WAF o Access) bloquee la confirmación; un pago cobrado sin pedido (stock o carrito cambiado); y repetir en el módulo nuevo fallas que ya tenía el QR (credenciales en logs, exención del WAF sin acotar). Las tres primeras ya se corrigieron en la tarea #150.

2. Cómo funciona vPOS por iframe

2.1 Fuentes y versiones vigentes

Tema Vigente Cómo se verificó
API POST https://vpos.infonet.com.py/vpos/api/0.3/... (producción) y https://vpos.infonet.com.py:8888/... (staging) Llamada de prueba el 2026-09-08: /0.3/single_buy responde JSON de error de autorización; /1.0/ no existe
Script del iframe bancard-checkout-5.0.1.js (y -sandbox para staging) Carpeta build del repo oficial Bancard/bancard-checkout-js; 4.0.0 y 5.0.1 tienen los mismos métodos
Documento oficial "Especificaciones Técnicas Single Buy vPOS 2.0, versión 1.23" (última revisión 13/05/2026) Descargado del portal de comercios el 2026-09-10: docs/analisis/eCommerce_bancard_compra_simple_version_1.23.1.pdf (no se publica ni se commitea). Reemplaza al manual público 0.3.1 como fuente; ver §2.7

"vPOS 2.0" es el nombre comercial; la ruta de la API sigue siendo 0.3. El manual 1.23 del portal documenta también el catastro de tarjetas, el cobro con token, 3DS, Zimple, preautorizaciones y factura electrónica; lo que sigue se contrastó con él (§2.7).

2.2 Flujo de una compra

sequenceDiagram
    participant N as Navegador
    participant SF as Storefront (Next)
    participant BE as Backend (Medusa)
    participant B as Bancard vPOS

    N->>SF: paso de revisión con "Tarjeta (Bancard)" elegida
    SF->>BE: POST /store/bancard-vpos/process {cart_id}
    BE->>BE: verificar stock y reservarlo; bloquear carrito; intento nuevo (shop_process_id)
    BE->>B: POST /vpos/api/0.3/single_buy (token md5)
    B-->>BE: process_id (un solo uso)
    BE-->>SF: process_id, attempt_id
    SF->>N: Bancard.Checkout.createForm(div, process_id)
    N->>B: iframe https://vpos.infonet.com.py/checkout/new?process_id=...
    Note over N,B: el cliente carga la tarjeta dentro del iframe de Bancard
    B->>BE: POST URL de confirmación (JSON con token, monto, response_code)
    BE->>BE: recalcular token con el monto guardado; idempotencia; carrito íntegro
    BE-->>B: 200 {"status":"success"} (antes de 30 s)
    BE->>BE: liberar reserva propia → processPaymentWorkflow: autoriza, captura, completa el carrito (pedido)
    B-->>N: postMessage → responseHandler(data)
    BE-->>N: SSE o sondeo: pago capturado + order_id
    SF->>N: página del pedido (datos que exige Bancard)

2.3 Llamadas al servidor de Bancard

Todas van desde nuestro backend con Content-Type: application/json. La clave privada nunca viaja: solo entra en el cálculo de un token md5.

Operación Endpoint Token (md5 de la concatenación) Notas
Iniciar compra POST /vpos/api/0.3/single_buy private_key + shop_process_id + amount + currency Devuelve process_id (20 caracteres). amount con dos decimales y punto: "10330.00"
Consultar POST /vpos/api/0.3/single_buy/confirmations private_key + shop_process_id + "get_confirmation" Devuelve la misma estructura que la confirmación. Usar si no llegó la confirmación (Bancard recomienda esperar 10 min)
Revertir POST /vpos/api/0.3/single_buy/rollback private_key + shop_process_id + "rollback" + "0.00" Solo mientras no esté "cuponada"; si ya lo está, la reversión es manual con el área comercial

Cuerpo de single_buy:

{
  "public_key": "…",
  "operation": {
    "token": "…",
    "shop_process_id": 100045,
    "currency": "PYG",
    "amount": "10330.00",
    "additional_data": "",
    "description": "Compulandia",
    "return_url": "https://shop.compulandia.com.py/py/payment/bancard/vpos/return?cart_id=cart_01…&session_id=payses_01…",
    "cancel_url": "https://shop.compulandia.com.py/py/payment/bancard/vpos/return?cart_id=cart_01…&session_id=payses_01…&canceled=1"
  }
}

Restricciones (manual 1.23): shop_process_id entero de hasta 15 dígitos, único por intento; currency solo PYG; description hasta 20 caracteres; additional_data hasta 255; return_url y cancel_url hasta 255 y se usan también cuando la tarjeta es rechazada. Campos opcionales: iva_amount (solo comercios bajo la ley de servicios digitales), preauthorization ("S"), extra_response_attributes (["payment_card_type"] devuelve credit o debit), billing (factura electrónica, §2.7), zimple ("S", con el celular en additional_data).

2.4 El iframe en el navegador

<script src="/vendor/bancard-checkout-5.0.1.js" nonce="…"></script>
<div id="bancard-iframe" style="width:100%"></div>
<script nonce="…">
  Bancard.Checkout.createForm("bancard-iframe", processId, {
    styles: { "button-background-color": "#0066CC" },
    responseHandler: function (data) { /* data.message, data.details, data.return_url */ }
  })
</script>

Lo que hace el script (leído de su código fuente):

  • Crea <iframe src="https://vpos.infonet.com.py/checkout/new?process_id=…&styles=…">, ancho 100 %, sin sandbox; en 5.x agrega allow="web-share".
  • Escucha message y solo acepta mensajes cuyo origen sea exactamente la URL compilada: https://vpos.infonet.com.py en el build normal y https://vpos.infonet.com.py:8888 en el -sandbox. Por eso staging necesita el build sandbox.
  • Ajusta la altura del iframe con lo que Bancard le manda (iframeHeight); no se puede fijar desde el comercio.
  • Si no se pasa responseHandler, hace window.location.replace(return_url?status=…). En una aplicación React conviene pasar siempre responseHandler (hay reportes de que la redirección rompe aplicaciones de una sola página).
  • Hace fetch a GET /checkout/allowed_styles para validar los estilos (afecta la CSP).
  • Otros formularios: Bancard.Cards.createForm (catastro de tarjeta), Bancard.Zimple.createForm, Bancard.Charge3DS.createForm, Bancard.Confirmation.loadPinPad (PIN de débito para cobro con token). destroy() para desmontar.

El valor de status que llega al responseHandler o a return_url (payment_success, payment_fail) es informativo: no prueba que el pago existe.

2.5 La confirmación (servidor a servidor)

Bancard hace POST a la URL de confirmación cargada en el perfil del portal (una por ambiente; no viaja en la petición). Cuerpo:

{ "operation": {
    "token": "…",
    "shop_process_id": "100045",
    "response": "S",
    "response_details": "…",
    "extended_response_description": "…",
    "currency": "PYG",
    "amount": "10330.00",
    "authorization_number": "123456",
    "ticket_number": "123456789123456",
    "response_code": "00",
    "response_description": "Transacción aprobada.",
    "security_information": {
      "customer_ip": "…", "card_source": "L", "card_country": "Paraguay",
      "version": "0.3", "risk_index": "0" } } }

Reglas:

  • Token de confirmación: md5(private_key + shop_process_id + "confirm" + amount + currency). Se recalcula con el monto que nosotros guardamos para ese intento y se compara con el recibido. Eso valida autenticidad y monto en un solo paso.
  • Respuesta: HTTP 200 con {"status":"success"} dentro de 30 segundos (manual 1.23; el manual viejo decía 60); si no, Bancard cierra la conexión y marca la confirmación como inválida en su traza. No responder 200 no significa que la transacción quede denegada: hay que consultar el estado con single_buy/confirmations.
  • Códigos: response vale S o N; response_code 00 aprobada, 05 no aprobado, 12 transacción inválida, 15 tarjeta inválida, 51 fondos insuficientes, y una tabla completa de unos 80 códigos de la red (páginas 84 a 86 del manual) que conviene cargar como catálogo para mostrar un texto útil al cliente. Llegan además iva_amount, iva_ticket_number y billing_response cuando aplican.
  • Monitoreo: cada 5 minutos Bancard hace POST a la misma URL con un JSON vacío para medir disponibilidad. Hay que responder 200 sin procesar nada.
  • Reintentos: Bancard no reintenta la confirmación. Si no llega, la indicación oficial es consultar single_buy/confirmations pasados 10 minutos y, si no hubo pago, revertir.
  • Índice de riesgo: risk_index de 0 a 9 para tarjetas de crédito locales (0 = no se pudo calcular; 1 a 3 bajo; 4 a 6 medio; 7 a 9 alto). Bancard pide que con riesgo medio se verifiquen datos con el cliente y con riesgo alto se confirme la operación con el cliente y con Bancard (riesgos@bancard.com.py) antes de entregar mercadería. Hay que guardarlo y mostrarlo a operaciones.
  • Página de resultado obligatoria (manual 1.23, pág. 74): mostrar fecha y hora, número de pedido (shop_process_id), importe y response_description. No mostrar authorization_number (cambio respecto del manual viejo), response_code, extended_response_description ni security_information. El sitio debe tener sección de Contacto y no guardar datos de tarjeta. El formulario embebido exige un ancho mínimo de 320 px.

2.6 Catastro de tarjetas, cobro con token y Zimple (segunda etapa)

Operación Endpoint Token Uso
Catastrar tarjeta POST /vpos/api/0.3/cards/new priv + card_id + user_id + "request_new_card" Devuelve process_id para Bancard.Cards.createForm
Listar tarjetas POST /vpos/api/0.3/users/{user_id}/cards priv + user_id + "request_user_cards" Devuelve alias_token, marca, últimos dígitos
Cobrar con token POST /vpos/api/0.3/charge priv + shop_process_id + "charge" + amount + currency + alias_token Respuesta síncrona; con débito pide PIN (loadPinPad), con 3DS abre Charge3DS
Borrar tarjeta DELETE /vpos/api/0.3/users/{user_id}/cards priv + "delete_card" + user_id + alias_token

Zimple (billetera): mismo single_buy con "zimple": "S" y el celular en additional_data, más el iframe Bancard.Zimple.createForm; datos de prueba: celular 0981123456, código 1234. Catastro: máximo 5 tarjetas por usuario, card_id y user_id enteros de hasta 19 dígitos, el alias_token sirve para una sola operación y vence en minutos; cédula de prueba 9661000. Cobro con token: number_of_payments (cuotas, solo crédito) y enviar siempre extra_response_attributes: ["confirmation.process_id"] para el flujo 3DS. Estas operaciones quedan fuera de la primera entrega; se listan para que el diseño no las cierre.

2.7 Lo que agrega el manual oficial 1.23 (2026-09-10)

Tema Manual público 0.3.1 Manual 1.23 del portal Efecto en el diseño
shop_process_id entero de largo variable entero de hasta 15 dígitos La derivación desde la sesión (ms × 100 + intento) da exactamente 15 dígitos: cabe, sin margen. Alternativa con margen: segundos desde 2026-01-01 × 100 + intento (11 dígitos)
description 100 caracteres 20 caracteres Enviar solo "Compulandia"; el número de pedido no entra
Tiempo para responder la confirmación 60 s 30 s Validar, persistir y responder antes de procesar
Reintentos de la confirmación no documentado no hay; consultar a los 10 min Conciliación obligatoria (ya prevista)
Página de resultado mostrar authorization_number no mostrarlo Corregir el diseño de la página del pedido
Reversión automática mientras no esté "cuponada" solo el mismo día; después, pedido de anulación por el portal (Soporte / Anulaciones) Confirma que el reembolso automático no sirve como red de seguridad
Zimple formato dudoso "zimple": "S" + celular en additional_data Pregunta cerrada
Errores de aplicación 6 claves agrega PublicKeyNotFoundError, ApplicationCredentialNotFoundError, tabla completa de errores de catastro y de la red El 403 UnauthorizedOperationError que recibimos es distinto de "clave pública no encontrada": apunta a una aplicación sin la operación habilitada
Índice de riesgo "aún no se envía" operativo, 0 a 9, con acciones esperadas del comercio Guardarlo en el intento y exponerlo en la vista del admin; regla operativa para riesgo alto
Bloqueo por rechazos — 7 rechazos de la misma tarjeta en 24 h (o 35 en 30 días) la bloquean en el comercio por 30 días Limitar reintentos en el checkout y avisar al cliente
Certificación — lista de test completa → botón "Solicitar certificación" con URL y usuario de prueba → soporte compra en el sitio → pestaña Producción Plan de la fase 5
Marca test_client — si viaja en el JSON, la llamada no marca la lista de test No usarla en las pruebas de certificación
Red — el comercio debe tener salida al puerto 8888 y su URL de confirmación debe soportar TLS 1.2 Verificar el egreso desde la VM y el túnel de Cloudflare
Estilos lista fija personalización y vista previa por ambiente en el portal, temas predefinidos, exportar JSON de estilos Definir el estilo en el portal, no en código
Factura electrónica — billing en single_buy y charge; consulta por RUC; cancelación hasta 24 h; innominada hasta Gs 7.000.000; el total de ítems debe coincidir con amount Fuera de alcance ahora; relevante para Compulandia más adelante
Soporte correo portal → Soporte → vPOS → "Pruebas de integración (desarrollo)" Canal para el 403 de las claves

3. Alta comercial y certificación

Paso Detalle Responsable
Solicitar vPOS Formulario en el portal de comercios o cac@bancard.com.py, (021) 416 1000. Documentación societaria de Compulandia (RUC, estatuto, acta, cuenta). Costo publicado: Gs 74.900 IVA incluido para plan en guaraníes; comisión por transacción no publicada Comercial / Administración
Acceso al portal Da claves pública (32 caracteres) y privada (40) por ambiente, perfil de la aplicación (nombre, logo, URL de confirmación), traza de llamadas, lista de pruebas y la documentación v1.22 TI
Cargar URL de confirmación de staging https://stg-store.compulandia.com.py/webhooks/bancard-vpos (el backend de staging se llama stg-store, ver inventario de Cloudflare) TI
Pasar la lista de pruebas Pago de prueba, consulta de confirmación, reversión; cada operación bien ejecutada destraba un ítem del checklist. Tarjeta de prueba publicada por un integrador: 5418630110000014 08/21 CVV 258 (verificar en el portal, puede haber cambiado) TI
Pedir certificación Bancard hace un pago de prueba y habilita la pestaña "Producción". Un integrador informa que piden una URL de pago válida durante al menos 10 días TI + Comercial
Producción Claves de producción, URL https://medusa.compulandia.com.py/webhooks/bancard-vpos, build normal del script TI

Importante: las credenciales del QR (BANCARD_QR_*, API comercios.bancard.com.py, autenticación Basic) no sirven para vPOS. Son productos y claves distintos.

4. Punto de partida en nuestra plataforma

4.1 Lo que existe y se reutiliza

Pieza Dónde Qué aporta
Proveedor de pago Bancard QR backend/src/modules/bancard-qr/ Patrón de AbstractPaymentProvider, cliente HTTP, tipos, estados
Webhook del QR backend/src/api/webhooks/bancard-qr/route.ts Formato de respuesta, idempotencia, búsqueda de sesión, captura
Canal en tiempo real backend/src/lib/sse-manager.ts, hook use-bancard-payment-sse.ts Avisar al navegador el resultado sin recargar. Es un mapa en memoria: no sobrevive a réplicas del servidor
Expiración y reversión qr-timeout-manager.ts, job bancard-qr-timeout.ts, ruta cancel Base para la conciliación de intentos abandonados
Representación de medios de pago storefront/src/lib/constants.tsx (paymentInfoMap), payment/index.tsx, payment-button/index.tsx Alta del medio nuevo en el selector y en el botón
CSP con nonce storefront/src/middleware.ts (ADR-0006) Punto único donde habilitar los orígenes de Bancard
Secretos GCP Secret Manager (ADR-0005 del backend) Donde viven las claves de vPOS
Entrada web Túnel de Cloudflare, sin IP pública (ADR-0001 de infraestructura) La confirmación de Bancard entra por Cloudflare hasta el backend

4.2 Flujo actual del QR y flujo nuevo

El QR hoy crea el pedido primero (el botón "Pagar con Bancard QR" llama a placeOrder): el proveedor responde authorized al completar el carrito, Medusa crea el pedido, reserva el stock y deja un pago autorizado sin capturar. Recién en la página del pedido se genera el QR, y el webhook captura el pago. Consecuencias que motivaron el cambio: pedidos sin pago que retienen stock sin límite (al expirar un QR se cancela el pago pero no el pedido), correo de "pedido realizado" antes del cobro, y pedidos que operaciones espera y nunca se pagan.

Con el modelo nuevo, verificado en Medusa 2.15.5:

  • Si el proveedor responde pending al autorizar, completar el carrito falla con error de autorización. Por eso el storefront no llama a placeOrder para QR ni tarjeta.
  • processPaymentWorkflow con acción captured sobre una sesión sin pago autoriza la sesión, captura y ejecuta completeCartAfterPaymentStep, que completa el carrito y crea el pedido. Es el mismo camino que usa el proveedor oficial de Stripe. Si el pedido ya existía, no lo duplica (busca el enlace order_cart).
  • completeCartWorkflow verifica y reserva stock; si falta, falla con INSUFFICIENT_INVENTORY e intenta reembolsar llamando a refundPayment del proveedor. Las variantes con allow_backorder se saltan la verificación.
  • Todo cambio de carrito (ítems, cantidades, dirección, envío, promociones, cliente) pasa por refreshPaymentCollectionForCartWorkflow: si el total cambia, borra todas las sesiones de pago. Crear una sesión para otro medio también borra las demás. Ese flujo expone un punto de validación (hooks.validate) que el repo ya usa para la promoción de transferencia.
  • El módulo de inventario permite crear reservas sueltas sobre ítems de un carrito (createReservationItems con line_item_id, ubicación y cantidad), sin pedido. Probado.

La transferencia bancaria no cambia: sigue con pedido primero y captura manual.

4.3 Moneda y montos

El storefront trata pyg como moneda sin centavos y Medusa v2 guarda los montos en unidades enteras de guaraníes. Bancard pide Decimal(15,2): el monto se envía como Number(total).toFixed(2) y se guarda ese mismo texto para recalcular el token. Cualquier diferencia de redondeo entre lo que se mandó y lo que se compara invalida la confirmación.

5. Diseño acordado

5.1 Decisión: pedido después del pago para medios confirmados por terceros

Aplica a Bancard QR y Bancard vPOS (y a cualquier pasarela futura con confirmación asíncrona). La transferencia bancaria mantiene el flujo actual. Se registra como ADR del backend con estas consecuencias:

Consecuencia Cómo se cubre
Un pago cobrado podría no tener pedido (stock, carrito cambiado, error interno) Reserva de stock al abrir el pago; bloqueo del carrito; si aun así falla, se crea el pedido igual con stock comprometido y se alerta (§5.5)
El cliente puede cerrar el navegador tras pagar El webhook crea el pedido igual; el correo de pedido realizado le llega
Operaciones no ve los pagos que no se concretaron Registro de intentos (§5.3) y vista en el admin (historia aparte)
Carritos con pago en curso quedan bloqueados Expiran solos a los 10 minutos; botón "cancelar pago" en el storefront
El QR actual funciona distinto Migra al modelo nuevo en una tarea propia, después del vPOS

5.2 Componentes a construir

Backend

Componente Responsabilidad
Módulo payment-attempt (común) Modelo payment_attempt (§5.3), servicio, enlaces a carrito y pedido, y las operaciones comunes: crear intento con reserva de stock, liberar reserva, marcar estados, completar el carrito tras el pago, manejar "pagado sin pedido"
Hook de bloqueo del carrito refreshPaymentCollectionForCartWorkflow.hooks.validate y el middleware de creación de sesiones: si el carrito tiene un intento pending, rechazar con "hay un pago en curso"
BancardVposClient singleBuy, getConfirmation, rollback; cálculo de tokens; URL base por ambiente; sin logs de claves
BancardVposProviderService initiatePayment solo estado local; authorizePayment devuelve captured si el intento está confirmed, si no pending; getWebhookActionAndData traduce la confirmación a captured / failed con session_id y amount; cancelPayment revierte el intento pendiente; refundPayment intenta rollback (solo sirve el mismo día) y si no puede deja el intento en refund_pending para gestión manual
POST /store/bancard-vpos/process Recibe cart_id; exige carrito con sesión vPOS pendiente y dueño correcto; si hay intento pending vigente lo devuelve; verifica stock y crea las reservas; guarda el intento con el total del carrito en texto "NNNN.00" y una huella de los ítems; llama a single_buy; devuelve process_id y attempt_id
POST /webhooks/bancard-vpos Sin autenticación de Medusa; JSON vacío → 200; busca el intento por shop_process_id; recalcula el token con el monto guardado; idempotencia y bloqueo por cart_id; verifica que la sesión exista y que el total y la huella del carrito coincidan; guarda la confirmación; responde 200; libera la reserva propia y dispara processPaymentWorkflow (captura y crea el pedido); guarda el order_id en el intento. Rechazo → rejected, libera reservas, avisa por SSE
POST /store/bancard-vpos/attempts/:id/cancel Cancelar pago en curso: rollback, liberar reservas, desbloquear carrito
GET /store/bancard-vpos/attempts/:id/status Estado del intento y order_id cuando existe. Respaldo del SSE (sondeo)
Job de conciliación Intentos pending de más de 10 minutos: consulta single_buy/confirmations; pagado → procesa como confirmación; no pagado → rollback, expired, libera reservas y desbloquea el carrito

Storefront

Componente Responsabilidad
Medio en paymentInfoMap e isBancardVpos Rótulo "Tarjeta de crédito o débito (Bancard)"; texto "Pagarás con tarjeta en el paso siguiente"
Paso de revisión con vPOS En lugar del botón "Realizar pedido": BancardVposFrame pide process_id con el cart_id, carga el script alojado localmente con nonce, monta el iframe, pasa siempre responseHandler, muestra estado y errores, ofrece "cancelar pago" y reintento tras rechazo. Sin botón que llame a placeOrder
Espera del pedido Tras el responseHandler, escucha SSE (hook existente) o sondea el estado hasta recibir order_id; luego navega a la página del pedido. Si en 60 s no hay pedido, muestra "estamos confirmando tu pago" con el correo como respaldo
Página del pedido Muestra lo que exige Bancard (fecha y hora, número de pedido, importe, descripción de la respuesta) y nada de lo prohibido (número de autorización, código de respuesta, respuesta extendida, información de seguridad). return_url y cancel_url apuntan a una ruta del storefront que resuelve carrito → pedido
Bloqueo visual Mientras el iframe está abierto, el checkout oculta los controles de edición; el bloqueo real lo hace el backend
CSP Orígenes de Bancard en frame-src y connect-src; script local en script-src 'self' con nonce

5.3 Registro de intentos (payment_attempt), común a las pasarelas

Un registro por intento de pago, en un módulo propio, independiente de Bancard:

Campo Uso
id, sequence sequence es un entero autoincremental; Bancard lo usa como shop_process_id
provider_id, method pp_bancard_vpos_bancard_vpos / pp_bancard_qr_bancard_qr; método legible (vpos, qr)
cart_id, payment_session_id, payment_id, order_id Enlaces; order_id se completa cuando el pedido existe
amount_text, currency El monto exacto enviado ("10330.00") para recalcular tokens
cart_fingerprint, items_count, customer_email, customer_name Huella e instantánea del carrito para detectar cambios y para que el intento se entienda sin abrir el carrito
external_reference process_id (vPOS) o hook_alias (QR)
status created → pending → confirmed / rejected / expired / rolled_back / failed / paid_without_order / refund_pending
reservation_ids Reservas de stock creadas al abrir el pago
confirmation, response_code, response_description, ticket_number, risk_index Lo recibido de la pasarela; sin datos de tarjeta. No se persisten authorization_number, security_information, token ni extended_response_description (src/lib/payment-attempt/confirmation-policy.ts): payment_session.data y payment.data llegan al cliente por la API store y el manual prohíbe mostrarlos; si operaciones los necesita, single_buy/confirmations los devuelve y el portal de comercios los lista por shop_process_id
Fechas creación, confirmación, expiración, reversión

shop_process_id sigue siendo único por intento: un rechazo genera un intento nuevo con otra secuencia. La description hacia Bancard usa la secuencia ("Compra #100045 Compulandia"), no el número de pedido, que todavía no existe.

5.4 Estados

stateDiagram-v2
    [*] --> created: intento creado, stock reservado, carrito bloqueado
    created --> pending: single_buy OK (process_id)
    created --> failed: single_buy error → libera reserva
    pending --> confirmed: confirmación válida, response_code 00 → pedido creado
    pending --> rejected: confirmación válida, response N → libera reserva
    pending --> expired: > 10 min sin pago → rollback, libera reserva
    pending --> rolled_back: cliente cancela el pago → rollback, libera reserva
    pending --> paid_without_order: pagado pero no se pudo crear el pedido
    paid_without_order --> confirmed: pedido creado con stock comprometido (manual o automático)
    paid_without_order --> refund_pending: reversión manual con Bancard
    confirmed --> [*]

5.5 Stock: reserva temporal y último recurso

  1. Al abrir el pago se verifica stock y se crean reservas sueltas por cada ítem con inventario gestionado. Mientras el intento está pending, esas unidades no las puede comprar otro cliente.
  2. Al confirmar, dentro del bloqueo del carrito, se borran las reservas propias y se completa el carrito, que vuelve a reservar a nombre del pedido.
  3. Si aun así falta stock (ventas por fuera de Medusa entre medio), se crea el pedido igual: se marcan las variantes afectadas con allow_backorder solo durante esa completación, dentro del bloqueo, y se restaura la marca al terminar. El pedido queda etiquetado "stock comprometido" y se alerta a operaciones. Alternativa descartada: revertir el cobro, porque la reversión automática en Bancard solo sirve el mismo día y después es manual.
  4. Reservas huérfanas (proceso caído): las limpia el job de conciliación junto con el intento.

5.6 Bloqueo del carrito durante el pago

  • Punto de validación de refreshPaymentCollectionForCartWorkflow y middleware de creación de sesiones: si el carrito tiene un intento pending, la operación falla con un mensaje claro.
  • El storefront traduce ese error a "Hay un pago en curso. Cancelalo para modificar el carrito" y ofrece el botón de cancelar.
  • Red de seguridad en el webhook: si la sesión ya no existe o el total o la huella del carrito no coinciden, no se crea el pedido; el intento pasa a paid_without_order con alerta.

5.7 Migración del QR al modelo nuevo (tarea propia, después del vPOS)

Hecha el 2026-09-11 (#263) según esta tabla; ver RF-003 reescrito. Pendiente: pruebas contra Bancard y despliegue separado.

Pieza del QR Cambio
authorizePayment Devuelve pending hasta la confirmación; captured después
Ruta de generación Recibe cart_id, usa el módulo de intentos (reserva, bloqueo, secuencia); hook_alias como external_reference
Webhook Deja de capturar a mano; usa la misma operación común que el vPOS (processPaymentWorkflow crea el pedido)
Expiración Solo revierte el QR y libera reservas; ya no hay pedido que cancelar. setTimeout en memoria se reemplaza por el job
Storefront Panel del QR pasa al paso de revisión; el botón deja de llamar a placeOrder; espera del pedido igual que el vPOS
Documentación RF-003, RF-004 (backend), RF-006 (storefront) e integración del QR se reescriben

Se despliega por separado, con pruebas propias en staging, para no tocar el medio que hoy funciona mientras se construye el vPOS.

6. Seguridad: amenazas y controles

# Amenaza Control Dónde
S1 Confirmación falsificada (alguien hace POST a nuestra URL) Recalcular el token md5 con la clave privada y el monto guardado; rechazar si no coincide; comparar también moneda y shop_process_id Webhook
S2 Manipulación del monto (pagar menos por un pedido) El monto entra en el token; además el backend fija el monto desde el carrito y nunca acepta uno del navegador Ruta process, webhook
S3 Pedido creado sin pago (confiar en payment_success del navegador) El storefront nunca llama a placeOrder para vPOS; solo el webhook o la conciliación completan el carrito Storefront, backend
S4 Confirmación duplicada o reprocesada Idempotencia por shop_process_id con estado final; bloqueo (módulo de locking de Medusa) por cart_id durante el proceso Webhook
S5 Reutilización de shop_process_id Secuencia en base; Bancard rechaza duplicados Modelo
S6 Fuga de la clave privada Solo en el backend, desde Secret Manager; nunca en NEXT_PUBLIC_*; prohibido en logs (hoy el cliente del QR loguea el Authorization completo, ver §7) Backend, runbook
S7 process_id interceptado o reutilizado Es de un solo uso y de vida corta; se entrega solo a quien tiene el cart_id; no se pone en URLs Ruta process
S8 Alguien consulta o dispara intentos de carritos ajenos Las rutas exigen el cart_id (no adivinable) y, si el carrito tiene cliente registrado, la sesión de ese cliente; nunca shop_process_id ni la secuencia como clave Rutas process, cancel y de estado
S9 Inyección de scripts (XSS) en el checkout CSP con nonce pasada a modo bloqueante antes de salir a producción; script de Bancard alojado localmente con nonce y su hash verificado al actualizar versión Middleware
S10 Mensajes postMessage de otro origen El script ya verifica event.origin; nuestro código no debe registrar oyentes de message sin verificar origen Storefront
S11 Nuestro sitio embebido por terceros (clickjacking) Se mantiene frame-ancestors 'none': nosotros somos el padre, no el hijo Middleware
S12 Confirmación bloqueada por Cloudflare Regla del WAF acotada a la ruta /webhooks/bancard-* y método POST (reemplaza la actual marcada "Testing", hallazgo #20); verificar que Cloudflare Access no exija identidad en esa ruta Cloudflare
S13 Confirmación tardía (> 30 s) Validar, persistir y responder 200 primero; el resto del proceso puede ir al bus de eventos (como hace la ruta nativa de Medusa, con demora configurable) Webhook
S14 Monitoreo de Bancard (JSON vacío cada 5 min) tratado como error Responder 200 sin cuerpo válido; no alertar por eso; sí alertar si deja de llegar Webhook, observabilidad
S15 Datos de tarjeta en nuestros sistemas No llegan nunca; no guardar ni loguear el JSON de confirmación fuera de la tabla de intentos; no mostrar security_information al cliente Diseño
S16 Mezcla de ambientes (claves de staging con script de producción) Variables por ambiente; build -sandbox en staging; prueba de arranque que verifique coherencia URL base ↔ script Configuración
S17 Reversión indebida rollback automático solo sobre intentos sin pago; sobre pagos confirmados, reversión manual y registrada Job, runbook
S18 Abuso de la ruta que crea intentos (muchas single_buy, muchas reservas de stock) Un intento pendiente por carrito; reservas ligadas al intento y liberadas con él; límite de peticiones en Cloudflare para /store/bancard-vpos/* Ruta process, Cloudflare
S19 Pérdida del aviso en tiempo real con réplicas del backend El SSE en memoria no funciona con 2+ instancias; el sondeo de estado es el respaldo obligatorio Storefront
S20 Pago cobrado sin pedido por falta de stock Reserva de stock al abrir el pago; último recurso: pedido con stock comprometido y alerta (§5.5) Ruta process, webhook
S21 Pago cobrado sobre un carrito modificado o con la sesión borrada Bloqueo del carrito mientras hay intento pendiente; en el webhook, verificación de sesión, total y huella; paid_without_order con alerta (§5.6) Hook, webhook
S22 Reservas de stock que nunca se liberan (proceso caído) Reservas guardadas en el intento; el job de conciliación las libera con el intento Job

Alcance PCI: el comercio no toca datos de tarjeta (equivalente a cuestionario SAQ A). Aun así, Bancard exige sección de Contacto en el sitio y la página de resultado descrita en §2.5.

7. Deuda del módulo QR que hay que corregir antes o junto con vPOS

Son fallas encontradas al revisar el código actual; el módulo nuevo no debe heredarlas.

# Hallazgo Dónde Riesgo
D1 Se loguean en texto plano el Authorization completo, la cadena de credenciales y la clave privada parcial al generar el QR bancard-client.ts, constructor y generateQR La clave privada queda en Loki y en la consola
D2 Se loguean las credenciales recibidas y las esperadas del webhook webhooks/bancard-qr/route.ts, validateBancardAuth Contraseña del callback en los logs
D3 El matcher de middlewares apunta a /webhooks/bancard pero la ruta es /webhooks/bancard-qr src/api/middlewares.ts Hoy es inofensivo (la lista de middlewares está vacía), pero confunde
D4 El canal SSE acepta cualquier valor que empiece con pk_ y refleja cualquier Origin con credenciales api/payment-events/[session_id]/route.ts Cualquiera que conozca un session_id puede escuchar el estado
D5 Exención del WAF para Bancard marcada "Testing" y sin alcance documentado Cloudflare (hallazgo #20 de la evaluación de infraestructura) Tráfico sin WAF hacia el backend
D6 Variables BANCARD_* ausentes en .env.example y .env.template Backend Quien despliega no sabe que existen
D7 Al expirar un QR se cancela el pago pero no el pedido; el stock queda reservado sin límite de tiempo qr-timeout-manager.ts, job bancard-qr-timeout.ts Inventario bloqueado por pedidos abandonados; desaparece con la migración del QR al modelo nuevo (§5.7). Mientras tanto, limpieza manual
D8 La expiración del QR depende de setTimeout en memoria; el job de respaldo corre cada 5 min qr-timeout-manager.ts Se pierde al reiniciar y no funciona con réplicas; el vPOS usa solo jobs sobre la base
D9 El hook de validación de promociones valida también en los recálculos internos (acción undefined/REPLACE), aunque su comentario dice que solo valida ADD: un carrito con la promoción de transferencia y una sesión de otro medio no se puede modificar src/workflows/hooks/promotion-payment-validation.ts Carritos trabados en el checkout; detectado el 2026-09-10 en las pruebas del bloqueo

D1, D2, D3, D4 y D6 se corrigieron en la tarea #150 (2026-09-09).

8. Cambios de infraestructura y configuración

8.1 CSP del storefront (src/middleware.ts)

Directiva Agregar Motivo
frame-src https://vpos.infonet.com.py y, en staging, https://vpos.infonet.com.py:8888 El iframe. El puerto forma parte del origen, se declara aparte
connect-src Los mismos orígenes El script consulta allowed_styles
script-src Nada, si el script se aloja en public/ y se carga con nonce Bancard recomienda alojarlo; evita depender de su CDN y de strict-dynamic

Hacer la lista por variable de entorno (NEXT_PUBLIC_BANCARD_VPOS_ORIGIN) para no tener el puerto de staging en producción. Después de esto, completar el ciclo del ADR-0006: recorrer el checkout en staging y pasar la CSP a modo bloqueante.

8.2 Backend

Variable Uso
BANCARD_VPOS_PUBLIC_KEY, BANCARD_VPOS_PRIVATE_KEY Claves por ambiente, desde Secret Manager
BANCARD_VPOS_API_URL https://vpos.infonet.com.py o …:8888
BANCARD_VPOS_RETURN_URL_BASE URL pública completa de la ruta de retorno del storefront (https://<tienda>/<país>/payment/bancard/vpos/return); el backend le agrega cart_id, session_id y canceled=1
BANCARD_VPOS_CONFIRMATION_TIMEOUT_MIN Minutos antes de conciliar un intento sin confirmación (10 por defecto); también es la vida del bloqueo del carrito y de la reserva

BANCARD_ORDER_PAYMENT_TIMEOUT_MIN se definió el 2026-09-09 y quedó sin uso con el modelo nuevo (no hay pedidos sin pago que expirar); se retira de las plantillas.

Migración para el modelo de intentos (se aplica en start.sh, ADR-0002). Enlaces defineLink intento ↔ carrito e intento ↔ pedido.

8.3 Cloudflare

  • Regla del WAF: permitir POST a medusa.compulandia.com.py/webhooks/bancard-* y stg-store.compulandia.com.py/webhooks/bancard-* sin otros paths; retirar la marca "Testing". Bancard no publica sus IPs salientes; no filtrar por IP sin confirmación escrita.
  • Verificar que Cloudflare Access no cubra esa ruta (el inventario muestra políticas de identidad sobre medusa.compulandia.com.py; Bancard no puede autenticarse).
  • Límite de peticiones para /store/bancard-vpos/*.
  • Registrar el cambio en infraestructura/docs/cambios.md y regenerar el inventario.

8.4 Observabilidad

  • Métrica o alerta si el monitoreo de Bancard (POST vacío cada 5 min) deja de llegar en 15 min.
  • Alerta por confirmaciones con token inválido (posible ataque o desajuste de monto).
  • Panel: intentos por estado, tiempo entre single_buy y confirmación.

9. Preguntas abiertas para Bancard

Cerradas con el manual 1.23 (2026-09-10): documento vigente, tabla de códigos, reintentos de la confirmación (no hay), formato de Zimple, moneda (solo PYG), datos de prueba de Zimple y catastro.

Siguen abiertas:

  1. Por qué la aplicación de staging responde UnauthorizedOperationError en create_single_buy, create_single_rollback y get_single_buy_confirmation con las claves del portal (§2.7).
  2. IPs salientes de las confirmaciones, si las publican.
  3. ¿La misma cuenta de comercio del QR puede tener vPOS, o es un alta nueva?
  4. Comisión por transacción y plazos de habilitación.
  5. Tarjeta de prueba vigente para pago ocasional en staging (el manual solo trae cédula y Zimple).

10. Plan por fases

Fase Contenido Depende de
0 · Trámite URL de confirmación de staging en el portal, preguntas de §9, certificación al final Comercial / TI
1 · Saneamiento Hecho (tarea #150): logs de credenciales, matcher, SSE, plantillas de variables. Falta: CSP relevada en staging y en modo bloqueante —
1b · ADR "Pedido después del pago para medios confirmados por terceros; transferencia con pedido primero", con reserva temporal de stock y bloqueo del carrito —
2 · Backend común Módulo payment-attempt (modelo, enlaces, reserva y liberación de stock, completar carrito tras el pago, "pagado sin pedido"), hook de bloqueo del carrito, job de conciliación 1b
3 · Backend vPOS Cliente y tokens con pruebas, proveedor, rutas process / cancel / status, webhook con pruebas 2
4 · Storefront Medio en el selector, iframe en el paso de revisión, cancelar pago, espera del pedido, ruta de retorno, página del pedido, CSP 3
5 · Staging y certificación Escenarios de §6 (stock, carrito cambiado, navegador cerrado, rechazo y reintento, monitoreo), lista de pruebas del portal, certificación 3 y 4
6 · Producción Claves de producción, URL de confirmación, regla del WAF acotada, RF nuevo, runbook (rotación de claves, reversión manual, "pagado sin pedido", stock comprometido) Certificación
7 · Migración del QR §5.7, con despliegue propio 2 y 3 en producción

Historias aparte: vista de intentos de pago en el admin (común a las pasarelas: listado, detalle, acciones de consultar / revertir / crear pedido, widget en el pedido, alertas) y, a futuro, catastro de tarjetas, cobro con tarjeta guardada, Zimple, cuotas y facturación.

11. Fuentes