DEV Community

Christ-loisele Atidegla
Christ-loisele Atidegla

Posted on

Trois fournisseurs mobile money, trois modèles d'idempotence, dont deux qui n'en ont aucun

À trois semaines de l'échéance du 30 septembre, beaucoup d'équipes de l'UEMOA écrivent du code de paiement dans l'urgence. La BCEAO a reporté à cette date la connexion à la plateforme PI-SPI pour les banques, les établissements de monnaie électronique et les établissements de paiement. Fin juin, 80 participants étaient connectés et 74 institutions encore en phase de test réel. Le Sénégal mène l'Union avec 20 institutions autorisées au 2 avril.

PI-SPI règle l'interopérabilité entre institutions. Il ne règle pas ce que fait votre code quand un appel à Wave ou à Orange Money expire et que votre file de jobs le rejoue. C'est de ça que parle cet article, parce que c'est le bug que l'urgence produit et qu'on ne voit qu'en production, sur l'argent de quelqu'un d'autre.

Le scénario, en trois lignes

Un client valide sa commande. Vous appelez l'API du fournisseur. La requête met trente secondes, votre client HTTP abandonne, le job échoue, Laravel le relance.

Le paiement est-il passé une fois ou deux ? La réponse dépend entièrement du fournisseur, et les trois que j'ai intégrés répondent différemment.

MTN MoMo : une clé, et un piège dans sa réponse

MTN est le seul des trois à fournir une vraie clé d'idempotence. L'en-tête X-Reference-Id sur POST /collection/v1_0/requesttopay, qui doit être un UUID. Si vous y mettez votre numéro de commande, l'API refuse sans expliquer pourquoi.

Rejouez la même référence et MTN ne rejoue pas le paiement. Il répond :

HTTP/1.1 409 Conflict

{"code": "RESOURCE_ALREADY_EXIST"}
Enter fullscreen mode Exit fullscreen mode

Le piège est là. Beaucoup de code PHP traite tout ce qui n'est pas 2xx comme un échec, et mon driver faisait pareil. Suivez alors le chemin complet : l'appelant subit un timeout, rejoue avec la même clé, reçoit un 409, voit une exception, conclut que rien n'est passé, et repart avec une nouvelle référence. Cette nouvelle référence est une nouvelle demande de paiement. Le client reçoit un second prompt. S'il valide, il paie deux fois.

La clé a parfaitement fonctionné. C'est la lecture de sa réponse qui a produit le double débit.

// Une X-Reference-Id rejouée revient en 409 RESOURCE_ALREADY_EXIST.
// C'est la clé d'idempotence qui fonctionne, pas un rejet : la première
// requête a été acceptée et celle-ci n'a rien changé.
if ($response->status() === 409) {
    return new Transaction(
        status: PaymentStatus::Pending,
        amount: $request->amount,
        reference: $reference,
        provider: $this->name(),
        payer: $request->payer,
        raw: ['http_status' => 409, 'duplicate' => true],
    );
}
Enter fullscreen mode Exit fullscreen mode

Pas Succeeded. Un 409 dit que la première requête a été acceptée, pas qu'elle a abouti. L'état réel est celui qu'a atteint la demande d'origine, et cette réponse ne vous le donne pas. Donc Pending, et l'appelant interroge.

Wave : aucune clé d'idempotence

Wave n'a pas d'en-tête d'idempotence. Un appel répété à POST /v1/checkout/sessions crée une seconde session de paiement, tout simplement.

Il n'y a donc rien à lire correctement. Il faut construire la protection soi-même, et le seul point d'accroche est client_reference, que Wave plafonne à 255 caractères et vous renvoie sur le webhook.

$payload = [
    'amount' => $request->amount->forProvider(),
    'currency' => $request->amount->currency->value,
    // Wave plafonne ce champ à 255 caractères et le renvoie sur le
    // webhook, ce qui permet de rattacher un callback à une commande.
    'client_reference' => mb_substr($request->reference, 0, 255),
    // Lie la session à un seul numéro : une URL de paiement qui fuite
    // ne peut pas être réglée par quelqu'un d'autre et créditée ici.
    'restrict_payer_mobile' => $request->payer->e164(),
];
Enter fullscreen mode Exit fullscreen mode

Ce qui rend la référence utilisable, c'est que la recherche par référence existe côté lecture. Une session Wave a un identifiant préfixé cos-. Tout ce qui n'a pas ce préfixe est traité comme notre propre référence et cherché :

$path = str_starts_with($reference, 'cos-')
    ? '/v1/checkout/sessions/'.$reference
    : '/v1/checkout/sessions/search?client_reference='.urlencode($reference);
Enter fullscreen mode Exit fullscreen mode

Autrement dit, avec Wave vous ne pouvez pas empêcher la seconde session d'exister. Vous pouvez seulement la retrouver avant que le client ne la paie. La conséquence pratique est qu'il faut vérifier avant de rejouer, jamais après.

Le restrict_payer_mobile mérite un mot au passage. Sans lui, une URL de checkout qui circule peut être réglée par n'importe qui, et le paiement est crédité sur votre commande. Ce n'est pas un problème d'idempotence, mais c'est le deuxième champ que les intégrations pressées oublient.

Orange Money : order_id, et le même trou

Orange Money passe par une redirection web et renvoie un payment_url. Le seul identifiant qui vous appartienne est order_id.

$payload = [
    'merchant_key' => (string) $this->config['merchant_key'],
    'currency' => $this->requestCurrency($request->amount->currency),
    'order_id' => $request->reference,
    // Orange attend un nombre ici et non une chaîne, et le XOF se compte
    // en francs entiers, donc c'est l'entier tel quel.
    'amount' => (int) $request->amount->forProvider(),
    'return_url' => (string) $this->config['return_url'],
    'notif_url' => (string) ($request->callbackUrl ?? $this->config['notif_url']),
];
Enter fullscreen mode Exit fullscreen mode

Aucune garantie d'idempotence n'est offerte sur cet appel. Comme avec Wave, la référence est un handle de réconciliation, pas une protection.

Notez le (int) sur le montant. Le XOF est une devise à zéro décimale : mille francs s'écrit 1000, pas 100000. Envoyer des centimes à une API qui attend des francs multiplie le montant par cent, et c'est le genre d'erreur qu'un test unitaire attrape et qu'une intégration écrite en trois semaines n'attrape pas.

Le tableau

Fournisseur Clé d'idempotence Un appel rejoué produit Ce qu'il faut construire
MTN MoMo X-Reference-Id, UUID obligatoire 409 RESOURCE_ALREADY_EXIST lire le 409 comme une acceptation, pas comme une erreur
Wave aucune une seconde session de paiement client_reference plus recherche par référence avant tout rejeu
Orange Money aucune un second paiement order_id plus vérification de statut avant tout rejeu

Un seul des trois vous protège, et seulement si vous lisez sa réponse correctement.

L'état que les trois partagent

Il reste un cas commun aux trois, et c'est celui qui manque dans la plupart des intégrations que j'ai lues.

Quand la connexion tombe avant la réponse, vous ne savez pas si le fournisseur a reçu la requête. Répondre Failed invite à rejouer, et rejouer peut débiter. Répondre Succeeded est un mensonge.

} catch (ConnectionException $e) {
    // On ne sait pas si le fournisseur a reçu la requête. La déclarer
    // échouée inviterait à rejouer, et rejouer peut débiter deux fois.
    return $this->unknown($request, $reference, $e->getMessage());
}
Enter fullscreen mode Exit fullscreen mode

Le statut est Unknown, pas Failed. La différence est opérationnelle : Failed autorise une nouvelle tentative, Unknown impose une vérification d'abord. Une commande mobile-money:reconcile interroge ensuite le fournisseur pour tout ce qui n'a pas atteint un état final, parce qu'en pratique une partie des callbacks n'arrive jamais.

Si vous ne retenez qu'une chose : un paiement mobile money n'a pas deux issues mais trois. Réussi, échoué, et inconnu. Tant que votre code ne sait pas dire « je ne sais pas », il finira par dire « échec » à un paiement qui est passé.

Le paquet

Le code ci-dessus vient de catidegla/laravel-mobile-money, qui expose MTN MoMo, Wave et Orange Money derrière une seule interface Laravel. Wave couvre SN et CI, Orange Money CI, SN, ML, BF, CM et GN, MTN se configure par pays. Montants en unités mineures entières, XOF et XAF traitées comme les devises à zéro décimale qu'elles sont, parsing des numéros selon les plans de numérotation nationaux, vérification des webhooks et réconciliation pour les callbacks manquants.

https://github.com/catidegla/laravel-mobile-money

Si vous intégrez l'un de ces trois en ce moment, le point à vérifier dans votre propre code tient en une question : que fait votre driver quand le même appel part deux fois ?

Top comments (0)