Saltar a contenido

RF-003 — Pago con Bancard QR

Estado Migrado al modelo "pedido después del pago" (tarea #263, rama feature/bancard-vpos); pendiente de pruebas en staging y despliegue propio
Tipo Payment provider propio + workflows de intento de pago + rutas API + job programado
Ubicación src/modules/bancard-qr/ · src/workflows/bancard-qr/ · src/lib/payment-attempt/ · src/workflows/payment-attempt/ · src/api/store/bancard-qr/ · src/api/webhooks/bancard-qr/ · src/jobs/bancard-qr-reconcile.ts
Depende de Módulo Payment de Medusa — RF-000 · notificación en vivo — RF-004 · ADR-0006
Ver también Integración Bancard QR · Modelo de intentos de pago · RF-011 Bancard vPOS

Requisito

Cobrar mediante QR de la red Bancard: emitir un QR por carrito listo para pagar, exhibirlo al cliente para que lo abone desde su aplicación bancaria, crear el pedido recién cuando Bancard confirma el cobro, y revertir el QR ante vencimiento o cancelación liberando el stock reservado, sin dejar pedidos sin pago.

Solución adoptada

El QR usa el mismo modelo de intentos de pago que Bancard vPOS (ADR-0006). La sesión de pago se crea vacía; al llegar al paso de revisión el storefront abre el intento (process): el backend valida el carrito, reserva el stock, bloquea el carrito, pide el QR a Bancard y lo devuelve. Cuando Bancard confirma, el webhook ejecuta confirmPaymentAttemptWorkflow (processPaymentWorkflow → captura → completeCart), que crea el pedido con el pago ya capturado. Si el QR vence, la conciliación lo revierte y libera el stock.

sequenceDiagram
    participant SF as Storefront
    participant API as Backend
    participant BAN as Bancard
    SF->>API: Seleccionar Bancard QR · initiatePayment
    API-->>SF: Sesión pendiente, sin QR
    SF->>API: POST /store/bancard-qr/process {cart_id}
    API->>API: createPaymentAttempt (valida, reserva stock, bloquea carrito)
    API->>BAN: generate-qr-express (monto, "Compulandia <shop_process_id>")
    BAN-->>API: hook_alias · url · qr_data · supported_clients
    API->>API: intento pending · external_reference = hook_alias
    API-->>SF: QR y vigencia
    Note over SF,BAN: El cliente abona desde su aplicación bancaria
    BAN->>API: POST /webhooks/bancard-qr (Basic Auth)
    API-->>BAN: {status: success}
    API->>API: confirmPaymentAttempt → processPayment → completeCart → pedido
    API-->>SF: Evento SSE captured {order_id} (+ sondeo de respaldo)
    SF->>SF: cierra el checkout y muestra el pedido

Rutas y disparadores

Superficie Método Función
/store/bancard-qr/process POST {cart_id} Abrir el intento: reserva, bloqueo, QR de Bancard. Reutiliza el QR vigente si ya hay uno
/store/bancard-qr/sessions/:id/status GET ?cart_id Estado del intento (respaldo del SSE), con el QR vigente; sin datos internos
/store/bancard-qr/sessions/:id/cancel POST {cart_id} "Cancelar pago": reversión en Bancard, stock liberado, carrito desbloqueado
/store/bancard-qr/simulator/confirm POST Solo con BANCARD_QR_SIMULATOR=true (404 si no): simula el aviso de Bancard
/webhooks/bancard-qr POST Aviso de Bancard (confirmado, vencido, cancelado, fallido); crea el pedido
Job bancard-qr-reconcile cada 2 min Vence los QR sin confirmación más viejos que BANCARD_QR_TIMEOUT_MS: reversión y liberación de stock

Las rutas /store/bancard-qr/generate, /store/bancard-qr/cancel y /store/payment-status/:session_id, el temporizador en memoria (qr-timeout-manager.ts) y el job bancard-qr-cleanup del modelo anterior fueron eliminados en #263.

Reglas de negocio

  • El pedido se crea solo con el pago confirmado por Bancard (webhook o conciliación). El proveedor responde pending al autorizar mientras el intento no esté confirmed, por lo que completar el carrito desde el navegador no crea pedidos.
  • Mientras hay un QR vigente el carrito está bloqueado (payment_attempt_in_progress) y el stock reservado a nombre de sus ítems. Cancelar, vencer o rechazar libera ambos.
  • El webhook exige Basic Auth con credenciales propias, responde siempre HTTP 200 con el envoltorio de Bancard, verifica que el monto informado sea el del intento, e ignora reenvíos.
  • La sesión se localiza por data.attempt.external_reference = hook_alias (consulta jsonb, no filtrado en memoria).
  • No se persisten authorization_code, bin ni card_last_numbers del aviso (src/lib/payment-attempt/confirmation-policy.ts): payment.data llega al cliente por la API store.
  • Vencimiento por job sobre la base (sin temporizadores en memoria). Si al revertir Bancard informa que devolvió un pago acreditado (confirmación perdida), se registra y se avisa en el resumen: el cliente recibe su dinero y no hay pedido.
  • Sesiones creadas antes de la migración (con hook_alias directo en data) siguen aceptando su confirmación por el camino anterior (captura del pedido ya creado) durante la transición.
  • El reembolso desde el admin intenta la reversión en Bancard; si no aplica, el intento queda refund_pending para gestión manual.

Estados del intento

Los del modelo común (src/lib/payment-attempt/types.ts): created → pending → confirmed (pedido) / rejected / expired / rolled_back / failed / paid_without_order / refund_pending.

Configuración

Variable Uso
BANCARD_QR_PUBLIC_KEY · BANCARD_QR_PRIVATE_KEY Credenciales de la API de Bancard (Basic Auth saliente)
BANCARD_QR_API_URL Base de la API; por defecto el ambiente productivo
BANCARD_QR_COMMERCE_CODE · BANCARD_QR_BRANCH_CODE Comercio y sucursal emisores del QR
BANCARD_QR_CALLBACK_USER · BANCARD_QR_CALLBACK_PASSWORD Credenciales exigidas al webhook entrante
BANCARD_QR_TIMEOUT_MS Vigencia del QR (por defecto 300.000 = 5 min); la conciliación revierte los más viejos
BANCARD_QR_SIMULATOR true solo en desarrollo: no sale nada hacia Bancard; el arranque falla en producción

Criterios de aceptación

# Criterio Prueba
1 Abrir el intento reserva stock, pide el QR con el monto del carrito y bloquea el carrito; una segunda apertura reutiliza el QR integration-tests/http/bancard-qr.spec.ts "abrir el QR"
2 Si Bancard falla, el intento queda fallido con la clave, sin reserva, y el carrito sigue modificable ídem
3 Webhook sin Basic Auth válido → error fijo de Bancard, sin procesar ídem "webhook"
4 Webhook confirmado con el monto exacto → pedido con el pago capturado, sin authorization_code/bin/card_last_numbers guardados; reenvío ignorado ídem
5 Webhook con otro monto → error y sin pedido; failed → intento rechazado, stock liberado, carrito modificable ídem
6 Cancelar revierte por hook_alias, libera el stock y desbloquea; segunda cancelación 400 ídem "cancelar y vencer"
7 Conciliación: QR reciente intacto; vencido → reversión y expired; red caída → pospuesto; pago revertido por Bancard → contado y avisado ídem + src/modules/bancard-qr/__tests__/*.unit.spec.ts

Limitaciones conocidas

  • La API de Bancard QR no ofrece consulta de estado por hook_alias: la conciliación solo puede revertir. Un pago cuya confirmación se perdió se devuelve al cliente al vencer el QR.
  • El aviso en tiempo real (SSE en memoria) no funciona con réplicas del backend; el sondeo del estado es el respaldo obligatorio.
  • Pendiente: pruebas contra el ambiente real de Bancard y despliegue separado del vPOS.