Saltar a contenido

⚠ 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 job bancard-qr-reconcile. Las rutas generate, cancel y payment-status y 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

  1. Resumen
  2. Arquitectura de Medusa v2
  3. Flujo de Checkout
  4. API de Bancard QR
  5. Mapa de Interacción
  6. Implementación
  7. Endpoints Requeridos
  8. 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_alias vincula 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

  1. Autenticación Basic Auth: Validar username y password en el header Authorization
  2. Verificar IP de origen: Solo aceptar requests de IPs de Bancard (opcional, como capa adicional)
  3. Idempotencia: El webhook puede llegar múltiples veces, usar hook_alias para 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


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