Idempotencia en pagos móviles: MTN la tiene, Wave y Orange Money no
Cómo tratan MTN MoMo, Wave y Orange Money un reintento de cobro, y por qué leer mal un 409 acaba en un doble débito al cliente.

Quedan tres semanas para el 30 de septiembre y buena parte de las entidades de la UEMOA está escribiendo código de pago contrarreloj. La BCEAO trasladó a esa fecha la conexión obligatoria a la plataforma PI-SPI para bancos, entidades de dinero electrónico y entidades de pago; a finales de junio había 80 participantes conectados y 74 instituciones aún en pruebas con dinero real, con Senegal al frente y 20 instituciones autorizadas a 2 de abril. PI-SPI resuelve la interoperabilidad entre entidades, no lo que hace tu código cuando una llamada a Wave o a Orange Money expira y la cola de trabajos la vuelve a lanzar.
El escenario se cuenta en tres líneas: un cliente valida su pedido, llamas a la API del proveedor, la petición tarda treinta segundos, tu cliente HTTP abandona, el job falla y Laravel lo relanza. La pregunta es si el pago pasó una vez o dos, y la respuesta depende por completo del proveedor.
MTN: la clave existe, la respuesta se lee mal
MTN es el único de los tres con una clave de idempotencia de verdad: la cabecera X-Reference-Id en POST /collection/v1_0/requesttopay, que debe ser un UUID. Si metes ahí tu número de pedido, la API rechaza sin decir por qué. Repetir la misma referencia no repite el cobro: devuelve 409 Conflict con el código RESOURCE_ALREADY_EXIST.
El agujero no está en la clave, sino en su lectura. Mucho código PHP da por fallido todo lo que no sea 2xx, y la cadena acaba así: el llamante sufre un timeout, rejuega con la misma clave, recibe el 409, ve una excepción, concluye que no pasó nada y pide una referencia nueva. Esa referencia nueva es una nueva solicitud de cobro, con un segundo aviso al cliente que, si acepta, paga dos veces. Un 409 dice que la primera petición fue aceptada, no que haya terminado; lo correcto es devolver Pending y consultar después el estado real.
Wave y Orange Money: sin clave, solo reconciliación
Wave no tiene cabecera de idempotencia. Repetir POST /v1/checkout/sessions crea una segunda sesión de pago, sin más. No hay nada que interpretar bien: la protección la montas tú, y el único punto de apoyo es client_reference, un campo que Wave limita a 255 caracteres y devuelve en el webhook. La búsqueda por referencia sí existe en lectura: las sesiones llevan el prefijo cos- y lo que no lo lleve se consulta en /v1/checkout/sessions/search?client_reference=. No puedes impedir que la segunda sesión exista, solo encontrarla antes de que el cliente la pague, así que la verificación va antes del reintento y nunca después. De paso, restrict_payer_mobile ata la sesión a un solo número: sin él, una URL de checkout filtrada la paga cualquiera y el abono cae en tu pedido.
Orange Money pasa por redirección web y devuelve un payment_url. El único identificador propio es order_id, y esa llamada no ofrece ninguna garantía de idempotencia: la referencia reconcilia, no protege. Ojo con el importe, porque el XOF no tiene decimales: mil francos se envían como 1000, no como 100000, y mandar céntimos a una API que espera francos multiplica el cargo por cien.
El autor mantiene un driver de Laravel para los tres proveedores, laravel-mobile-money. El patrón de fondo es el mismo en los tres casos: la ventana en la que un reintento se convierte en un segundo cobro está siempre ahí, y en dos de los tres proveedores nadie la cierra por ti. Con el plazo del 30 de septiembre encima, estos son los fallos que solo aparecen en producción y con dinero ajeno.
