Voici un bug qui n'en a pas l'air :
try {
$response = Http::timeout(30)->post($provider, $payload);
} catch (ConnectionException) {
return $this->markFailed($commande); // faux
}
La connexion a expiré, rien n'est revenu, donc le paiement a échoué. Raisonnable. Faux aussi, et d'une manière qui prend de l'argent à de vraies personnes.
Ce qu'un timeout vous dit réellement
Il vous dit que vous avez cessé d'écouter. C'est tout.
Le mobile money est asynchrone par construction. Vous envoyez une demande de collecte, l'opérateur pousse une invite sur le téléphone du client, et le client saisit un code PIN. Cela peut prendre quatre secondes ou quatre minutes. Il est peut-être dans un taxi. Il doit peut-être changer de SIM. Votre timeout de trente secondes est une décision que votre code a prise sur la durée pendant laquelle il garde une socket ouverte, et elle n'a aucun rapport avec le déroulement du paiement.
L'API de MTN est honnête à ce sujet d'une manière qui surprend au premier abord. requesttopay répond 202 Accepted avec un corps vide. Pas d'objet transaction, pas de statut, rien. Le seul identifiant dont vous disposez ensuite est le X-Reference-Id que vous avez généré et envoyé. Si vous ne l'avez pas conservé, le paiement existe et vous n'avez aucun moyen de vous renseigner dessus.
Il y a donc trois possibilités derrière un timeout, et la réponse ne permet pas de les distinguer :
- La requête n'est jamais arrivée. Rien ne s'est passé.
- La requête est arrivée, et l'opérateur sollicite le client en ce moment même.
- La requête est arrivée, le client a payé, et la réponse s'est perdue au retour.
Marquer la commande comme échouée et laisser le client réappuyer sur le bouton est correct pour le premier cas et catastrophique pour les deux autres.
Règle 1 : la clé est générée une fois et survit aux tentatives
Tous les opérateurs de ce secteur acceptent un identifiant fourni par le client. Cet identifiant est tout le mécanisme. Envoyez le même deux fois et l'opérateur reconnaît le second appel comme une répétition du premier plutôt que comme un nouveau paiement.
L'erreur la plus fréquente consiste à le générer au mauvais endroit :
// Faux. Une nouvelle clé à chaque tentative signifie que chaque tentative
// est un nouveau paiement.
function tenterPaiement(Commande $commande) {
return $gateway->collect(
idempotencyKey: Str::uuid(),
amount: $commande->total,
);
}
Ce code a une clé d'idempotence et aucune idempotence. La clé appartient au paiement logique, pas à la tentative. Elle doit donc être créée une seule fois et transportée à travers chaque nouvel essai.
Et il faut la persister avant l'appel, pas après. Si le processus meurt entre l'envoi et l'enregistrement, vous vous retrouvez avec un paiement sur lequel vous ne pouvez rien demander.
Règle 2 : en cas de doute, demandez, ne renvoyez pas
La seconde moitié : après un résultat ambigu, une nouvelle tentative ne devrait pas être une tentative du tout. Ce devrait être une question.
// Après tout timeout ou réponse ambiguë
$connu = $gateway->status($request->idempotencyKey);
if ($connu->status === PaymentStatus::Pending) {
// On attend toujours le client. Ne rien faire. Interroger plus tard.
return;
}
Interroger d'abord, envoyer seulement si l'opérateur n'a jamais entendu parler de la clé. Cela transforme une situation ambiguë en situation certaine en utilisant les registres de l'opérateur, qui font autorité, plutôt qu'une déduction depuis votre côté d'une connexion coupée.
Et le webhook ne doit pas être le seul chemin
Les webhooks dans la région ne sont pas assez fiables pour constituer la seule route vers un état final. Les callbacks se perdent, arrivent avec plusieurs minutes de retard, ou arrivent deux fois.
Interrogez donc tout ce qui reste en attente, avec un intervalle croissant, et une fenêtre au-delà de laquelle vous arrêtez et escaladez vers un humain. Traitez le callback comme une indication qu'il vaut la peine de demander tôt, jamais comme la réponse elle-même.
La forme qui fonctionne pour moi :
- Ne livrer que sur un succès confirmé, jamais sur
PENDING, jamais sur un callback seul. - Interroger les transactions en attente selon un intervalle croissant.
- Abandonner après une fenêtre définie et marquer la transaction pour revue plutôt que de deviner.
- Journaliser chaque changement d'état avec la clé d'idempotence, parce que la question « qu'avons-nous réellement envoyé » est la première de tout litige.
En une ligne
Un timeout n'est pas un résultat. C'est l'absence de résultat. Conservez la clé, demandez avant de renvoyer, et ne laissez jamais une réponse perdue devenir un second débit.
Le package d'où vient tout ceci est catidegla/laravel-mobile-money : MTN MoMo, Wave et Orange Money derrière une seule API Laravel. 105 tests, et le README précise exactement quels chemins ont été confrontés à un sandbox réel et lesquels ne reposent que sur la documentation.
Top comments (0)