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-0xxcuando 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 %, sinsandbox; en 5.x agregaallow="web-share". - Escucha
messagey solo acepta mensajes cuyo origen sea exactamente la URL compilada:https://vpos.infonet.com.pyen el build normal yhttps://vpos.infonet.com.py:8888en 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, hacewindow.location.replace(return_url?status=…). En una aplicación React conviene pasar siempreresponseHandler(hay reportes de que la redirección rompe aplicaciones de una sola página). - Hace
fetchaGET /checkout/allowed_stylespara 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 consingle_buy/confirmations. - Códigos:
responsevaleSoN;response_code00aprobada,05no aprobado,12transacción inválida,15tarjeta inválida,51fondos 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ásiva_amount,iva_ticket_numberybilling_responsecuando aplican. - Monitoreo: cada 5 minutos Bancard hace
POSTa 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/confirmationspasados 10 minutos y, si no hubo pago, revertir. - Índice de riesgo:
risk_indexde 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 yresponse_description. No mostrarauthorization_number(cambio respecto del manual viejo),response_code,extended_response_descriptionnisecurity_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
pendingal autorizar, completar el carrito falla con error de autorización. Por eso el storefront no llama aplaceOrderpara QR ni tarjeta. processPaymentWorkflowcon accióncapturedsobre una sesión sin pago autoriza la sesión, captura y ejecutacompleteCartAfterPaymentStep, 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 enlaceorder_cart).completeCartWorkflowverifica y reserva stock; si falta, falla conINSUFFICIENT_INVENTORYe intenta reembolsar llamando arefundPaymentdel proveedor. Las variantes conallow_backorderse 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
(
createReservationItemsconline_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¶
- 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. - 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.
- Si aun así falta stock (ventas por fuera de Medusa entre medio), se crea el pedido igual:
se marcan las variantes afectadas con
allow_backordersolo 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. - 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
refreshPaymentCollectionForCartWorkflowy middleware de creación de sesiones: si el carrito tiene un intentopending, 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_ordercon 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
POSTamedusa.compulandia.com.py/webhooks/bancard-*ystg-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.mdy 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_buyy 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:
- Por qué la aplicación de staging responde
UnauthorizedOperationErrorencreate_single_buy,create_single_rollbackyget_single_buy_confirmationcon las claves del portal (§2.7). - IPs salientes de las confirmaciones, si las publican.
- ¿La misma cuenta de comercio del QR puede tener vPOS, o es un alta nueva?
- Comisión por transacción y plazos de habilitación.
- 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¶
- Manual "Integración con eCommerce Bancard Compra Simple 0.3.1" (PDF público de la AFD): https://www.afd.gov.py/userfiles/files/transparencia/ecommerce-bancard-compra-simple-version-0-3-1.pdf
- Repositorio oficial del script: https://github.com/Bancard/bancard-checkout-js y fuente https://github.com/Bancard/bancard-connectors/tree/master/vpos/checkout/javascript
- Estilos permitidos (público): https://vpos.infonet.com.py/checkout/allowed_styles
- Producto y alta: https://www.bancard.com.py/vpos · https://comercios.bancard.com.py/productos/vpos
- Guía de un integrador sobre certificación y ambientes: https://wiki.adamspay.com/devzone:concepts:service:bancard-vpos
- Librerías de referencia (vPOS 2.0): https://github.com/zrkb/bancard · https://github.com/hschimpf/bancard-sdk · https://github.com/krugerdavid/laravel-bancard
- Problemas reportados: issues #6, #11, #19 y #20 de
bancard-checkout-js - Medusa: proveedor de pago https://docs.medusajs.com/resources/commerce-modules/payment/payment-provider;
flujo
processPaymentWorkflowverificado en@medusajs/core-flows2.15.5 - Interno: Integración Bancard QR · RF-003 · RF-004 · ADR-0006 del storefront (CSP) · ADR-0001 de infraestructura (entrada web) · evaluación de infraestructura y seguridad (hallazgo #20)