⚠ Documento del modelo anterior. Desde la tarea #263 (2026-09-11) el QR sigue el modelo "pedido después del pago" (ADR-0006): el QR se genera sobre el carrito con
POST /store/bancard-qr/process, el pedido lo crea el webhook y el vencimiento lo resuelve el jobbancard-qr-reconcile. Las rutasgenerate,cancelypayment-statusy el temporizador en memoria ya no existen. La descripción vigente y breve está en RF-003; lo que sigue se conserva por el contrato con la API de Bancard (generate-qr-express,payments/revert, formato del webhook), que no cambió.
Integración Bancard QR - Documentación Técnica¶
Índice¶
- Resumen
- Arquitectura de Medusa v2
- Flujo de Checkout
- API de Bancard QR
- Mapa de Interacción
- Implementación
- Endpoints Requeridos
- Consideraciones de Seguridad
Resumen¶
Este documento describe la integración del método de pago Bancard QR en el backend de e-commerce basado en Medusa v2. Bancard QR permite a los usuarios pagar escaneando un código QR desde aplicaciones bancarias de Paraguay.
Características del Flujo Bancard QR¶
- Pago asíncrono: El usuario escanea el QR y paga desde su app bancaria
- Confirmación vía webhook: Bancard notifica el resultado del pago
- Identificador único:
hook_aliasvincula la solicitud con la confirmación
Arquitectura de Medusa v2¶
Estructura de Módulos de Pago¶
src/modules/
├── bank-transfer/ # Método existente (referencia)
│ ├── index.ts # Registro del módulo
│ └── service.ts # Payment provider service
│
└── bancard-qr/ # NUEVO: Método Bancard QR
├── index.ts # Registro del módulo
├── service.ts # BancardQRPaymentProvider
└── types.ts # Tipos TypeScript
Configuración en medusa-config.ts¶
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "./src/modules/bank-transfer",
id: "bank_transfer",
},
{
resolve: "./src/modules/bancard-qr",
id: "bancard_qr",
options: {
publicKey: process.env.BANCARD_QR_PUBLIC_KEY,
privateKey: process.env.BANCARD_QR_PRIVATE_KEY,
apiUrl: process.env.BANCARD_QR_API_URL || "https://comercios.bancard.com.py",
},
},
],
},
}
Flujo de Checkout¶
Entidades Involucradas¶
CART (Carrito)
├── id: "cart_123"
├── items: [productos]
├── total: 250000
└── payment_collection ──────────────────┐
│
PAYMENT_COLLECTION │
├── id: "paycol_456" │
├── cart_id: "cart_123" ◄────────────────┘
├── amount: 250000
├── status: "not_paid" | "authorized" | "captured"
└── payment_sessions[] ──────────────────┐
│
PAYMENT_SESSION │
├── id: "payses_789" │
├── payment_collection_id: "paycol_456" ◄┘
├── provider_id: "bancard_qr"
├── status: "pending" | "authorized" | "captured"
└── data: { hook_alias, qr_url, ... }
ORDER (se crea DESPUÉS)
├── id: "order_abc"
├── payment_collection_id: "paycol_456"
└── status: "pending" | "completed"
Línea de Tiempo del Checkout (Flujo A - Implementado)¶
| Paso | Acción | ¿Existe Order? | Estado Payment |
|---|---|---|---|
| 1 | Usuario agrega productos al cart | ❌ No | - |
| 2 | Se crea Payment Collection | ❌ No | - |
| 3 | Se crea Payment Session (SIN QR aún) | ❌ No | pending |
| 4 | Usuario confirma checkout → Se completa cart | ✅ Sí | authorized |
| 5 | Frontend llama a /store/bancard-qr/generate |
✅ Sí | pending |
| 6 | Usuario escanea QR y paga | ✅ Sí | pending |
| 7 | Webhook de Bancard llega | ✅ Sí | captured |
Diagrama de Secuencia¶
FRONTEND BACKEND BANCARD API
──────── ─────── ───────────
│ │ │
│ POST /store/payment-collections │
│ { cart_id } │ │
├──────────────────────>│ │
│ │ │
│ { id: "paycol_456" } │ │
│<──────────────────────┤ │
│ │ │
│ POST /store/payment-collections/{id}/payment-sessions
│ { provider_id: "bancard_qr" } │
├──────────────────────>│ │
│ │ │
│ { id: "session_123", status: "pending" } │
│<──────────────────────┤ (NO llama a Bancard) │
│ │ │
│ POST /store/carts/{id}/complete │
├──────────────────────>│ │
│ │ │
│ { order_id: "order_abc" } │
│<──────────────────────┤ (Orden creada) │
│ │ │
│ POST /store/bancard-qr/generate │
│ { order_id: "order_abc" } │
├──────────────────────>│ │
│ │ │
│ │ POST /generate-qr-express
│ │ { amount, description }│
│ ├────────────────────────>│
│ │ │
│ │ { hook_alias, qr_url } │
│ │<────────────────────────┤
│ │ │
│ { qr_url, hook_alias, supported_clients } │
│<──────────────────────┤ │
│ │ │
│ [Mostrar QR] │ │
│ [Iniciar polling] │ │
│ │ │ │
│ │ │ POST /webhooks/bancard │
│ │ │ { hook_alias, status } │
│ │ │<────────────────────────┤
│ │ │ │
│ │ │ [Capturar pago] │
│ │ │ │
│ GET /store/payment-status/{session_id} │
├──────────────────────>│ │
│ │ │
│ { status: "captured", order_id: "order_abc" } │
│<──────────────────────┤ │
│ │ │
│ [Redirigir a confirmación] │
▼ ▼ ▼
API de Bancard QR¶
Solicitar QR (Request)¶
POST https://api.bancard.com.py/qr/express
Content-Type: application/json
{
"amount": 2500,
"description": "Pago por pedido Nro. 52000",
"promotions": []
}
Solicitar QR (Response)¶
{
"status": "success",
"qr_express": {
"amount": 2500,
"hook_alias": "SEZIL21870",
"description": "Pago por pedido Nro. 52000",
"url": "https://desa.infonet.com.py:8035/s4/public/selling_qr_images/SEZIL21870_xxx.png",
"created_at": "05/12/2025 15:02:59",
"qr_data": "000201010212..."
},
"supported_clients": [
{
"name": "Banco Familiar S.A.E.C.A.",
"logo_url": "https://desa.infonet.com.py:8035/s4/public/entity_logos/familiar0.png"
},
{
"name": "Banco Continental S.A.E.C.A.",
"logo_url": "https://desa.infonet.com.py:8035/s4/public/entity_logos/continental0.png"
}
// ... más bancos
]
}
Webhook de Confirmación (Callback)¶
Bancard envía esta información a tu endpoint cuando el pago es procesado:
POST /webhooks/bancard
Content-Type: application/json
{
"payment": {
"hook_alias": "SQNFO35640",
"status": "confirmed",
"response_code": "00",
"response_description": "Pago exitoso",
"amount": 500,
"currency": "GS",
"installment_number": 10,
"description": "Coca Cola 1 Ltr.",
"date_time": "09/09/2024 13:59:39",
"ticket_number": 787568952225421,
"authorization_code": "124453",
"commerce_name": "Supermercado Maravilla",
"branch_name": "Sucursal San Vicente",
"created_at": "2025-09-09T13:54:02.000Z",
"bin": "433234",
"merchant_code": "51111",
"payer": {
"name": "Hugo",
"lastname": "Quintero"
},
"card_last_numbers": 1234,
"account_type": "TC"
}
}
Mapa de Interacción¶
Responsabilidades por Componente¶
Frontend (Storefront/Next.js)¶
| Responsabilidad | Descripción |
|---|---|
| Crear Payment Collection | Llamar API cuando usuario inicia checkout |
| Crear Payment Session | Solicitar generación del QR |
| Mostrar QR | Renderizar imagen + lista de bancos |
| Polling de estado | Consultar /payment-status cada 3-5 segundos |
| Manejar timeout | Mostrar mensaje si QR expira |
| Redirigir | Llevar a página de confirmación cuando status=captured |
Backend (Medusa v2)¶
| Componente | Responsabilidad |
|---|---|
BancardQRPaymentProvider |
Implementar AbstractPaymentProvider |
initiatePayment() |
Llamar API Bancard, retornar QR data |
authorizePayment() |
Marcar como autorizado |
capturePayment() |
Marcar como capturado |
getWebhookActionAndData() |
Parsear webhook de Bancard |
| Webhook endpoint | Recibir POST de Bancard, procesar pago |
| Status endpoint | Retornar estado actual para polling |
Bancard API¶
| Responsabilidad | Descripción |
|---|---|
| Generar QR | Crear código con datos de transacción |
| Procesar pago | Recibir pago del cliente |
| Notificar | Enviar webhook a tu endpoint |
Implementación¶
Estructura de Archivos (Implementada)¶
src/
├── modules/
│ └── bancard-qr/
│ ├── index.ts # Registro del módulo
│ ├── service.ts # BancardQRPaymentProvider
│ ├── types.ts # Interfaces y tipos
│ └── bancard-client.ts # Cliente HTTP para Bancard API
│
├── api/
│ ├── webhooks/
│ │ └── bancard/
│ │ └── route.ts # POST callback de Bancard (con Basic Auth)
│ │
│ └── store/
│ ├── bancard-qr/
│ │ ├── generate/
│ │ │ └── route.ts # POST genera QR llamando a Bancard
│ │ └── cancel/
│ │ └── route.ts # POST cancela pago manualmente
│ │
│ └── payment-status/
│ └── [session_id]/
│ └── route.ts # GET estado del pago (polling)
│
├── jobs/
│ └── bancard-qr-timeout.ts # Job para reversión automática por timeout
Variables de Entorno¶
# Bancard QR Configuration
BANCARD_QR_PUBLIC_KEY=your_public_key
BANCARD_QR_PRIVATE_KEY=your_private_key
BANCARD_QR_API_URL=https://comercios.bancard.com.py # opcional, este es el default
BANCARD_QR_COMMERCE_CODE=your_commerce_code
BANCARD_QR_BRANCH_CODE=your_branch_code
# Timeout para reversión automática (en milisegundos)
BANCARD_QR_TIMEOUT_MS=300000 # 5 minutos (default)
URL del Endpoint de Bancard¶
El endpoint para generar QR tiene la siguiente estructura:
{API_URL}/external-commerce/api/0.1/commerces/{COMMERCE_CODE}/branches/{BRANCH_CODE}/selling/generate-qr-express
Autenticación con Bancard API¶
La autenticación usa Basic Auth con el siguiente formato:
Authorization: Basic {TOKEN}
Donde {TOKEN} es la conversión a base64 de:
app/{BANCARD_QR_PUBLIC_KEY}:{BANCARD_QR_PRIVATE_KEY}
Ejemplo en código:
const credentials = `app/${publicKey}:${privateKey}`
const token = Buffer.from(credentials).toString("base64")
// Header: Authorization: Basic {token}
Endpoints Requeridos¶
Endpoints del Storefront (existentes en Medusa)¶
| Método | Endpoint | Descripción |
|---|---|---|
| POST | /store/payment-collections |
Crear payment collection para cart |
| POST | /store/payment-collections/{id}/payment-sessions |
Crear payment session (SIN QR) |
| POST | /store/carts/{id}/complete |
Completar cart (crea orden) |
Endpoints Nuevos (Implementados)¶
| Método | Endpoint | Descripción |
|---|---|---|
| POST | /store/bancard-qr/process |
Abre el intento y genera el QR sobre el carrito (#263) |
| GET | /store/bancard-qr/sessions/{id}/status |
Estado del intento (respaldo del SSE) |
| POST | /store/bancard-qr/sessions/{id}/cancel |
Cancela el pago (reversión) |
| POST | /webhooks/bancard-qr |
Recibir confirmación de Bancard (requiere Basic Auth); crea el pedido |
(Antes de #263: generate {order_id}, cancel, payment-status/{session_id}.)
Detalle del Endpoint /store/bancard-qr/generate¶
Request:
{
"order_id": "order_01ABC123..."
}
Response:
{
"hook_alias": "SEZIL21870",
"qr_url": "https://bancard.../QR.png",
"qr_data": "000201010212...",
"amount": 250000,
"supported_clients": [
{ "name": "Banco Familiar", "logo_url": "..." },
{ "name": "Banco Continental", "logo_url": "..." }
],
"session_id": "bcqr_xxx",
"already_generated": false
}
Notas:
- Se llama DESPUÉS de que la orden fue creada
- Si ya se generó un QR para esa orden, retorna already_generated: true
- El session_id se usa para el polling de estado
Detalle del Endpoint /store/bancard-qr/cancel¶
Request:
{
"session_id": "bcqr_xxx", // Opcional
"order_id": "order_01ABC123..." // Opcional
}
Nota: Se debe proporcionar session_id o order_id (al menos uno).
Response exitosa:
{
"success": true,
"message": "Payment canceled successfully",
"session_id": "bcqr_xxx",
"hook_alias": "SEZIL21870"
}
Response si ya estaba cancelado:
{
"success": true,
"message": "Payment was already canceled/expired",
"session_id": "bcqr_xxx",
"already_canceled": true
}
Response de error:
{
"success": false,
"message": "Cannot cancel a payment that has already been captured"
}
Reversión de Pagos¶
Casos de Reversión¶
El sistema debe solicitar reversión del pago a Bancard en los siguientes escenarios:
| Caso | Descripción | Acción |
|---|---|---|
| Cancelación del cliente | El cliente solicita cancelar el pago antes de completarlo | Llamar API de reversión de Bancard |
| Timeout de 5 minutos | Se excede el límite máximo de espera sin confirmación | Reversión automática + cancelar orden |
| Fallo interno | Error al registrar el pago en el backend después de confirmación | Reversión + notificar al cliente |
Endpoint de Reversión de Bancard¶
PUT {API_URL}/external-commerce/api/0.1/commerces/{COMMERCE_CODE}/branches/{BRANCH_CODE}/selling/payments/revert/{hook_alias}
Authorization: Basic {TOKEN}
Content-Type: application/json
Posibles Respuestas de Reversión¶
1. Reversa exitosa:
{
"status": "success",
"payment": {
"status": "success",
"response_code": "00",
"response_description": "Reversa exitosa"
}
}
2. Reversa rechazada (pago fallido):
{
"status": "success",
"payment": {
"status": "error",
"response_code": "12",
"response_description": "Transacción inválida"
}
}
3. QR no pagado (cancelación):
{
"status": "error",
"messages": [
{
"level": "error",
"key": "ConfirmationNotFoundError",
"dsc": "No se encontró confirmación de pago"
}
]
}
4. QR ya reversado:
{
"status": "success",
"messages": {
"status": "error",
"response_code": 71,
"response_description": "Ya fue reversado previamente"
}
}
Job de Reversión Automática¶
El sistema incluye un job programado que se ejecuta cada minuto para revertir automáticamente los QRs que excedieron el timeout configurado.
Configuración:
- Schedule: * * * * * (cada minuto)
- Timeout configurable via BANCARD_QR_TIMEOUT_MS (default: 5 minutos)
Funcionamiento:
1. Busca payment sessions de Bancard QR con QR generado
2. Filtra las que fueron creadas hace más de BANCARD_QR_TIMEOUT_MS
3. Excluye las que ya están capturadas, canceladas o expiradas
4. Para cada una pendiente, llama a rollback() en Bancard
5. Actualiza el estado de la sesión a "expired"
Autenticación del Webhook (Callback)¶
Basic Auth para el Endpoint de Callback¶
El endpoint /webhooks/bancard requiere autenticación Basic Auth. Bancard enviará las mismas credenciales que usamos para llamar a su API en el header Authorization.
Formato de credenciales:
Authorization: Basic base64("apps/{PUBLIC_KEY}:{PRIVATE_KEY}")
Validación en el endpoint:
El webhook valida que las credenciales coincidan con BANCARD_QR_PUBLIC_KEY y BANCARD_QR_PRIVATE_KEY:
const expectedCredentials = `apps/${publicKey}:${privateKey}`
const receivedCredentials = Buffer.from(base64Token, "base64").toString("utf-8")
// Debe coincidir: receivedCredentials === expectedCredentials
Formato de Respuestas del Webhook¶
Respuesta Exitosa¶
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success",
"messages": [
{
"level": "success",
"key": "Confirmed",
"description": "Pago recibido con exito"
}
]
}
Respuesta de Error¶
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "error",
"messages": [
{
"level": "error",
"key": "ConfirmedError",
"description": "No se pudo procesar la confirmacion"
}
]
}
Nota importante: Ambas respuestas retornan HTTP 200. El campo status indica si fue exitoso o no.
Consideraciones de Seguridad¶
Validación del Webhook¶
- Autenticación Basic Auth: Validar username y password en el header
Authorization - Verificar IP de origen: Solo aceptar requests de IPs de Bancard (opcional, como capa adicional)
- Idempotencia: El webhook puede llegar múltiples veces, usar
hook_aliaspara detectar duplicados
Manejo de Errores¶
| Escenario | Acción |
|---|---|
| QR expirado (5 min) | Reversión automática + mostrar mensaje |
| Pago cancelado por cliente | Llamar reversión + actualizar estado |
| Webhook duplicado | Ignorar si ya fue procesado, retornar success |
| Fallo al registrar pago | Reversión + notificar usuario |
| Timeout de polling | Mostrar estado pendiente con instrucciones |
Datos Sensibles¶
- Nunca loguear datos completos del payer
- Almacenar solo últimos 4 dígitos de tarjeta
- Encriptar credenciales de API en variables de entorno
- No exponer credenciales del webhook en logs
Referencias¶
- Medusa v2 Payment Module
- Medusa v2 Payment Provider
- Documentación API Bancard (requiere acceso)
Documento generado: Diciembre 2025 Versión: 1.2
Changelog¶
| Versión | Fecha | Cambios |
|---|---|---|
| 1.0 | 09/12/2025 | Documentación inicial |
| 1.1 | 09/12/2025 | Agregado: Reversión de pagos, Autenticación webhook, Formato de respuestas |
| 1.2 | 09/12/2025 | Implementado: Job de timeout automático, Endpoint de cancelación manual, Webhook con Basic Auth |