Saltar a contenido

Modelo de intentos de pago

Qué es La pieza común que usan las pasarelas cuyo pago confirma un tercero (Bancard QR y Bancard vPOS) para crear el pedido después del cobro
Ubicación src/lib/payment-attempt/ (modelo y utilidades) · src/workflows/payment-attempt/ (operaciones) · src/lib/payment-attempt-admin/ (lectura y acciones del admin)
Decisión de fondo ADR-0006 · Pedido después del pago para medios de terceros
Lo usan RF-003 Bancard QR · RF-011 Bancard vPOS · RF-010 Intentos en el admin

Por qué existe

Con transferencia bancaria el pedido se crea primero y el pago se registra después. Con Bancard —QR o tarjeta— quien decide si hubo pago es Bancard, y avisa por su cuenta. Si el pedido se creara antes, cada carrito abandonado en la pantalla de pago dejaría un pedido sin cobrar. Por eso el pedido se crea con la confirmación en la mano, y mientras tanto hace falta algo que represente el cobro en curso: el intento de pago.

Un intento guarda todo lo necesario para cerrar el cobro sin depender del navegador del cliente: el monto exacto que se le mandó a la pasarela, su referencia, el carrito y su huella, los ítems con stock reservado y el resultado.

Dónde vive (sin tabla propia)

Se descartó una tabla propia: el modelo se apoya en lo que Medusa ya tiene.

Dato Lugar
Intento en curso payment_session.data.attempt
Intentos anteriores de la misma sesión payment_session.data.attempts_history
Historial que debe sobrevivir al borrado de la sesión cart.metadata.payment_attempts
Stock retenido Reservas del módulo de inventario, a nombre de cada line_item_id

Medusa borra las sesiones de pago al cambiar de medio; por eso el resumen de cada intento cerrado se archiva también en el carrito, que es lo que lee el admin.

shop_process_id sin secuencia

Bancard exige un identificador numérico de hasta 15 dígitos por operación. Se deriva del id de la sesión, que es un ULID y codifica su marca de tiempo: milisegundos × 100 + número de intento (src/lib/payment-attempt/shop-process-id.ts). No hace falta secuencia ni tabla, y cada reintento sobre el mismo carrito obtiene un número nuevo, como pide Bancard.

Huella del carrito

Al abrir el pago se calcula cart_fingerprint: ítems (variante y cantidad, ordenados), total y moneda. Al confirmar se recalcula. Si no coincide, el carrito cambió entre el pago y la confirmación y no se crea el pedido: el intento queda paid_without_order para que operaciones lo resuelva.

Estados

created → pending → confirmed  (pedido creado)
                  → rejected | expired | rolled_back | failed
                  → paid_without_order → (completar a mano) → confirmed
                  → refund_pending
Estado Significado
created Stock reservado; todavía sin referencia de la pasarela
pending La pasarela devolvió su referencia; el cliente está pagando
confirmed Pago confirmado; pedido creado (order_id) o en creación
rejected La pasarela informó rechazo
expired Venció sin pago; reservas liberadas
rolled_back Revertido a pedido del cliente o de operaciones
failed Error al iniciar con la pasarela
paid_without_order Se cobró pero no se pudo crear el pedido (stock, carrito cambiado)
refund_pending Hay que devolver el dinero a mano

created, pending y paid_without_order bloquean el carrito; confirmed sin order_id también. Los demás son finales y dejan lugar a un intento nuevo.

Operaciones

Workflow Qué hace
create-payment-attempt Valida el carrito, verifica y reserva el stock a nombre de sus ítems, calcula shop_process_id y huella, y deja el intento created
update-payment-attempt Aplica cambios al intento en curso (referencia de la pasarela, paso a pending, metadatos)
confirm-payment-attempt Bajo bloqueo del carrito: verifica la integridad, libera la reserva propia, marca confirmed y ejecuta processPaymentWorkflow (autoriza, captura y completa el carrito). Si algo falla, paid_without_order. Es idempotente
fail-payment-attempt Cierra el intento sin pago: libera reservas, lo manda al historial y archiva el resumen en el carrito. Con eso el carrito queda desbloqueado
force-complete-cart-for-attempt Último recurso desde el admin para un paid_without_order por falta de stock: habilita allow_backorder solo durante la completación y lo restaura después

Bloqueo del carrito

findBlockingAttemptForCart (src/lib/payment-attempt/cart-lock.ts) busca el intento que bloquea. Los hooks de validación del carrito lo consultan y responden con el mensaje estable payment_attempt_in_progress, que el storefront traduce a lenguaje claro. Impide agregar ítems, actualizar el carrito, cambiar de medio de pago y completar desde el navegador.

Cuidado al tomar bloqueos: completeCart de Medusa bloquea por id de carrito. Un workflow propio que use esa misma clave espera 30 segundos y falla; por eso los bloqueos propios usan la clave payment-attempt:<cart_id>.

Qué no se guarda

payment_session.data y payment.data llegan al navegador del dueño del carrito o del pedido por la API store. El manual 1.23 de Bancard prohíbe mostrar el número de autorización, el código de respuesta, la respuesta extendida y la información de seguridad. Como no hay un lugar nativo oculto al cliente, esos campos no se persisten (src/lib/payment-attempt/confirmation-policy.ts): token, authorization_number, security_information, extended_response_description y, para el QR, authorization_code, bin y card_last_numbers. Se conservan ticket_number, response_code (uso interno) y risk_index. Si operaciones los necesita, están en el portal de comercios de Bancard.

Cómo lo lee el admin

src/lib/payment-attempt-admin/listing.ts arma la vista con una consulta SQL que une las tres fuentes (intento en curso, historial de la sesión y archivo del carrito) y las presenta como una sola lista. Las acciones —consultar en la pasarela, revertir, liberar el stock, crear el pedido igual— se despachan por un registro de pasarelas (gateways.ts), así que agregar una pasarela nueva no obliga a tocar la vista. Ver RF-010.

Agregar una pasarela nueva

  1. Proveedor de pago que responda pending al autorizar mientras el intento no esté confirmado.
  2. Abrir el intento con create-payment-attempt y guardar la referencia de la pasarela con update-payment-attempt.
  3. Webhook que valide la autenticidad contra el monto guardado y llame a confirm-payment-attempt o fail-payment-attempt.
  4. Job de conciliación para los intentos sin confirmación.
  5. Registrar la pasarela en src/lib/payment-attempt-admin/gateways.ts para que aparezca en el admin con sus acciones.