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¶
- Proveedor de pago que responda
pendingal autorizar mientras el intento no esté confirmado. - Abrir el intento con
create-payment-attempty guardar la referencia de la pasarela conupdate-payment-attempt. - Webhook que valide la autenticidad contra el monto guardado y llame a
confirm-payment-attemptofail-payment-attempt. - Job de conciliación para los intentos sin confirmación.
- Registrar la pasarela en
src/lib/payment-attempt-admin/gateways.tspara que aparezca en el admin con sus acciones.