DEV Community

Christ-loisele Atidegla
Christ-loisele Atidegla

Posted on

Le franc CFA n'a pas de centime, et votre code de paiement l'ignore probablement

Je développe des SaaS Laravel à Cotonou. J'ai intégré le mobile money trois fois, une fois pour MTN, une fois pour Wave, une fois pour Orange Money, et à chaque fois j'ai rencontré les mêmes problèmes dans le même ordre.

Le premier coûte de l'argent réel.

Il n'existe pas de centime de franc CFA

Il y a une règle que nous avons tous apprise tôt et que nous avons cessé de questionner : ne jamais stocker un montant en flottant, toujours en nombre entier de la plus petite unité. Centimes, pence, kobo.

C'est un bon conseil. C'est aussi faux pour environ un cinquième des devises du monde.

La norme ISO 4217 attribue à chaque devise un exposant, c'est-à-dire son nombre de décimales. La plupart valent 2. Quelques-unes valent 3. Et un groupe vaut zéro, ce qui signifie qu'il n'existe aucune sous-unité. Le XOF et le XAF, les deux francs CFA, en font partie. Le franc guinéen aussi, tout comme le yen japonais et le won coréen, dont on parle davantage.

Le centime de franc CFA n'existe pas. Pas rare, pas déprécié. Il n'existe pas.

Donc quand votre type Money suppose que les unités mineures sont des centièmes et que vous facturez 10 000 XOF à un client :

$montant = 10000;                    // dix mille francs
$provider->charge($montant * 100);   // un million de francs
Enter fullscreen mode Exit fullscreen mode

Une surfacturation d'un facteur cent. Le typage passe. La revue de code passe, parce que la ligne ressemble exactement à celle que vous avez écrite cent fois pour l'EUR. Vous le découvrez quand un client appelle.

La correction consiste à faire porter l'exposant par la devise elle-même, et à refuser ce qui ne peut pas être représenté :

Money::of(10000, Currency::XOF)->forProvider();   // "10000"

Money::of(1500.50, Currency::XOF);
// InvalidMoneyException : XOF est une devise sans decimale,
// 1500.5 ne peut donc pas etre represente
Enter fullscreen mode Exit fullscreen mode

Refuser compte plus qu'il n'y paraît. Si vous arrondissez, vous choisissez une direction, et quelqu'un se trompe d'un franc sur chaque transaction, indéfiniment, sans que rien ne vous le signale. Un franc, ce n'est rien. Un franc multiplié par toutes les transactions pendant deux ans, c'est un problème de rapprochement comptable dont vous vous passeriez.

Les plans de numérotation changent

Toute intégration mobile money doit savoir à quel réseau appartient un numéro, puisque cela détermine quel opérateur traite le paiement. L'approche évidente est une table de préfixes.

Les tables de préfixes deviennent obsolètes. Le Bénin est passé aux numéros à dix chiffres en 2024, et toutes les règles écrites en dur avant ce changement ont cessé de correspondre.

Le mode de défaillance est désagréable parce que ce n'est pas une erreur. Une table obsolète ne vous dit pas qu'elle est obsolète. Elle achemine un paiement vers un opérateur qui le rejette, et vous obtenez une transaction échouée avec un message sur un compte invalide, ce qui vous envoie chercher au mauvais endroit.

Ma conclusion : analyser le numéro correctement, et renvoyer null plutôt que deviner quand le plan de numérotation d'un marché n'est pas documenté et stable. Un parseur qui admet son ignorance est plus utile qu'un parseur confiant et faux, parce que le code appelant peut alors poser la question au client.

Un délai d'attente n'est pas un échec

Celui-ci fait payer les gens deux fois, et il mérite son propre article, mais voici l'essentiel.

Vous envoyez une demande de collecte. La connexion expire. Rien n'est revenu, donc le réflexe est de marquer l'opération comme échouée.

Sauf que la requête est très probablement arrivée. Le mobile money est asynchrone par nature : l'opérateur envoie une invite sur le téléphone du client, et le client saisit un code PIN, peut-être trente secondes plus tard, peut-être cinq minutes. Votre timeout HTTP n'a aucun rapport avec le fait que le paiement soit en cours ou non. Il vous dit seulement que vous avez cessé d'écouter.

Une nouvelle tentative après un timeout peut donc créer un second débit sur la même commande.

Deux choses l'empêchent. Générer une clé d'idempotence à la création de la demande, la conserver à travers les tentatives, et exiger qu'appeler deux fois avec la même clé ne produise jamais deux débits. Et interroger le statut avec cette clé plutôt que de renvoyer une nouvelle demande quand vous avez un doute.

Les opérateurs écrasent des distinctions qui comptent

MTN rapporte un client qui refuse l'invite et un client qui n'y répond jamais avec le même statut : FAILED.

Ce sont deux situations complètement différentes. Celui qui a refusé a pris une décision. Celui dont le téléphone était au fond d'un sac pendant dix minutes n'a rien décidé. L'un mérite une relance, l'autre non, et si votre système confond les deux vous allez soit harceler le premier groupe, soit abandonner le second.

Je les sépare en Cancelled et Expired et je laisse l'application décider. Les codes de statut d'un opérateur sont une entrée, pas un modèle métier.

Ce que j'en ai fait

Après la troisième intégration, j'en ai fait un package : catidegla/laravel-mobile-money. Une seule API Laravel pour MTN MoMo, Wave et Orange Money.

composer require catidegla/laravel-mobile-money
Enter fullscreen mode Exit fullscreen mode
$transaction = MobileMoney::collect(CollectionRequest::make(
    amount: Money::of(1500, Currency::XOF),
    payer: Msisdn::parse('+229 01 97 12 34 56'),
    reference: 'ORDER-42',
));
Enter fullscreen mode Exit fullscreen mode

Aucun driver n'est nommé. Le numéro du payeur détermine quel réseau traite la demande.

Où en est réellement le projet, parce que je préfère le dire que vous laisser le découvrir. Les trois drivers sont implémentés et couverts par 105 tests qui vérifient la forme exacte des requêtes documentées par chaque opérateur, et la CI est verte sur Laravel 12. Seul MTN a été confronté à un sandbox réel, et encore partiellement : son produit Collections a atteint le plafond d'abonnements d'Azure et n'accepte plus aucun nouveau développeur. Ce test partiel a tout de même révélé un vrai bug de double débit dans ma propre gestion de l'idempotence. Wave et Orange demandent des identifiants marchands que je n'ai pas. Considérez donc ces deux contrats comme documentés et non prouvés, et faites votre propre test en sandbox avant toute mise en production.

C'est d'ailleurs la demande. Si vous avez des identifiants marchands chez l'un des trois et dix minutes devant vous, vos retours m'intéressent.

Pourquoi maintenant

La BCEAO a repoussé au 30 septembre 2026 la date limite pour que les banques, les émetteurs de monnaie électronique et les établissements de paiement se raccordent au rail de paiement instantané PI-SPI, les institutions de microfinance ayant jusqu'en juin 2027. Fin juin, 80 participants étaient raccordés et 74 autres en phase de test.

Beaucoup de monde écrit donc du code d'intégration de paiement dans la zone UEMOA en ce moment. Si vous en faites partie, j'espère que cette liste vous évitera au moins la première erreur.

Top comments (0)