Intégrer une passerelle de paiement, c'est la fonctionnalité rare où un bug n'est pas un désagrément : c'est de l'argent qui sort du système. Sur TribuneJustice, une legaltech où les clients paient des avocats pour une prestation à venir, l'enjeu était plus haut : il fallait un flux d'escrow, pas juste un bouton « débiter la carte ».
Voici à quoi ressemblait concrètement l'intégration du mobile money au Cameroun (MeSomb, via MTN Mobile Money et Orange Money), et l'ingénierie qui empêche une stack de paiement de fuiter.
Pourquoi le paiement est le code le plus risqué d'une legaltech
Sur une plateforme juridique, un paiement n'est pas « acheter un produit ». C'est : un client place de l'argent en dépôt → un avocat réalise la prestation → les fonds sont libérés. Autrement dit, le moteur de paiement porte :
- Un cycle de vie d'escrow (
held→released→refunded) qui ne doit jamais sauter d'état. - Deux parties (client et expert) plus une commission plateforme sur une seule transaction.
- L'obligation de gérer échecs, reprises et double-notifications sans jamais créditer deux fois.
Si un SaaS tolère « le paiement est passé deux fois, on remboursera », un escrow juridique ne le peut pas. Voilà comment je l'ai construit.
Une abstraction, trois fournisseurs
Première décision : ne jamais coder en dur un fournisseur. Chaque paiement passe par une interface unique — ainsi, changer ou ajouter un PSP ne touche jamais la logique métier.
interface PaymentGatewayInterface
{
public function getGatewayName(): string;
public function initiatePayment(PaymentInitDTO $dto, string $internalReference): PaymentResultDTO;
public function verifyWebhook(Request $request): bool;
public function handleWebhook(Request $request): PaymentResultDTO;
public function checkStatus(string $paymentReference): PaymentResultDTO;
}Un routeur choisit la bonne implémentation selon le contexte : MeSomb est l'agrégateur par défaut pour tout le mobile money en XAF (MTN MoMo, Orange Money), tandis que CinetPay/Flutterwave gèrent cartes et paiements internationaux.
if (in_array($method, [Transaction::METHOD_MTN_MOMO, Transaction::METHOD_ORANGE_MONEY], true)) {
return $this->mesomb; // mobile money (XAF) → MeSomb
}
return $this->cinetPay; // cartes & international → CinetPay / FlutterwaveLa partie dure : signer ses requêtes comme le SDK
MeSomb n'utilise pas une simple clé d'API. Chaque requête porte un en-tête Authorization construit à partir d'une signature HMAC-SHA1 sur une requête canonique. Le SDK PHP officiel (hachther/mesomb-php) la construit d'une manière trèsprécise — et si l'ordre d'insertion de tes en-têtes ne correspond pas exactement, toutes les requêtes sont rejetées avec un 401.
Les détails subtils qui font toute la différence :
- Les en-têtes doivent être insérés dans l'ordre exact :
content-type(pour le non-GET),host,x-mesomb-date,x-mesomb-nonce. - La requête canonique joint les segments de chemin rawurlencoded et hache le corps avec
sha1. serialize_precisiondoit être à-1pour que la sérialisation JSON de PHP ne produise pas des flottants subtilement différents.
// Le scope et le string-to-sign reproduits ligne par ligne depuis le SDK
$scope = $date->format('Ymd').'/payment/mesomb_request';
$stringToSign = 'HMAC-SHA1'."\n".$timestamp."\n".$scope."\n".sha1($canonicalRequest);
$signature = hash_hmac('sha1', $stringToSign, $this->secretKey);AOperation::executeRequest et Signature::signRequest. C'est cette attention qui transforme un 401 opaque en un vrai paiement fonctionnel.Deux autres pièges bien réels avec les numéros de téléphone camerounais :
- Les utilisateurs saisissent leur numéro sous une dizaine de formats (
+237677123456,00237 677...,67712 34 56). Je retire tout ce qui n'est pas un chiffre, j'enlève le préfixe237, et je garde les 9 chiffres locaux. - L'opérateur est déduit du préfixe (
69,655,656→ Orange ; sinon MTN) — car l'API a besoin de savoir quel portefeuille débiter.
L'idempotence : le bug qui double-encaisse
La signature de tout moteur de paiement, c'est une course conditionnelle. Imagine une confirmation où un webhook et une vérification manuelle de statut arrivent pour la même transaction — ou où le PSP rejoue une notification. Sans protection, deux requêtes concurrentes passent toutes les deux le garde if ($tx->status !== 'completed'), et on génère deux factures et on marque la prestation comme payée deux fois.
Le correctif est un verrouillage pessimiste à l'intérieur de la transaction :
return DB::transaction(function () use ($transaction) {
$lockedTx = Transaction::lockForUpdate()->findOrFail($transaction->id);
if ($lockedTx->status === Transaction::STATUS_COMPLETED) {
return $lockedTx; // déjà traitée → idempotent
}
$invoice = Invoice::create([...]);
$lockedTx->update(['status' => Transaction::STATUS_COMPLETED, ...]);
// ... marquer la ServiceRequest comme payée
});lockForUpdate() prend un verrou de ligne pour qu'une transaction concurrente attende, relise l'état frais, et constate qu'il est déjà completed. Sans ça, deux requêtes au même millisecond passent toutes deux le test — et l'argent coule deux fois. C'est invisible dans les tests unitaires et quasi impossible à reproduire à la main.Le même motif protège chaque transition qui touche à l'argent : la libération d'escrow (retour anticipé si déjà released) et le remboursement (retour anticipé si déjà refunded).
Le cycle de vie de l'escrow
Chaque transaction démarre pending avec escrow_status = held. Les fonds sont en dépôt, pas chez l'expert.
'escrow_status' => Transaction::ESCROW_HELD, 'status' => Transaction::STATUS_PENDING,
Quand la prestation est livrée, l'escrow est libéré et le reversement expert programmé à J+7 (une décision owner : laisser une fenêtre de refroidissement). La commission plateforme (~20 %) est calculée en amont et le montant netpart chez l'expert sous forme de payout en attente.
$scheduledAt = $now->copy()->addDays(self::PAYOUT_DELAY_DAYS); // 7
$lockedTx->update([
'escrow_status' => Transaction::ESCROW_RELEASED,
'payout_due_at' => $scheduledAt,
]);
ProfessionalPayout::updateOrCreate([...], [
'commission_amount' => $commissionAmount,
'net_amount' => $netAmount,
'status' => 'pending',
'scheduled_at' => $scheduledAt,
]);Chaque transition d'état est validée et immuable : impossible de libérer des fonds depuis un escrow déjà remboursé, et le chemin de remboursement a sa propre machine à états.
Le remboursement comme machine à états (ADR-001)
Rembourser n'est pas un bouton. Ce sont des scénarios aux économies différentes :
- Remboursement total (annulation avant le début) : 100 % du brut payé, frais PSP absorbés par la plateforme.
- Remboursement partiel (annulation après le début) : un pourcentage saisi au cas par cas par un admin.
- Litige : 100 % du brut plus recouvrement de la commission plateforme auprès de l'expert — soit débitée de sa balance interne, soit récupérée via un payout net négatif (
net_amountnégatif) absorbé sur ses futurs versements.
Le remboursement verrouille la ligne et retourne anticipé si déjà remboursé — une reprise ne rembourse jamais deux fois.
Si le remboursement passerelle échoue, la ligne reste pending et sera rejouée, plutôt que de faire échouer toute l'opération.
En cas de litige, la plateforme récupère sa commission : débit de la balance d'abord, puis payout due négatif sur les gains futurs.
Au remboursement, la demande de service est annulée, la facture marquée remboursée, la timeline mise à jour. Aucun état orphelin.
Les webhooks : ce qui casse en production
Les webhooks sont là où l'argent est réellement confirmé, et ils sont pleins de pièges.
Vérification de signature avec protection anti-rejeu. MeSomb envoie un en-tête X-MeSomb-Webhook-Signature au format t=<timestamp>,v1=<signature>. Je vérifie avec HMAC-SHA256, je rejette tout ce qui sort d'une fenêtre d'horodatage de 10 minutes (anti-rejeu), et je compare avec hash_equals (temps constant, contre les attaques temporelles).
if (abs(time() - $timestamp) > 600) {
return false; // tentative de rejeu
}
$payloadToSign = "{$timestamp}.{$rawBody}";
$expectedSignature = hash_hmac('sha256', $payloadToSign, $secret);
return hash_equals($expectedSignature, $receivedSignature);Les formats de payload changent sous vos pieds. La passerelle a évolué d'un payload legacy {status, pk, reference} vers une forme imbriquée {event_type, data.object}. Le handler normalise les deux — le processeur de webhook doit survivre à la dérive d'API de votre fournisseur.
Un bug de log qui pouvait faire tomber les paiements. Le canal de log de production (Nightwatch) avait parfois un problème de permissions fichier. Tout Log:: qui levait une exception à l'intérieur du handler faisait échouer tout le webhook avec un 500 — que le PSP rejouait en boucle, bloquant potentiellement des paiements légitimes. Le correctif : envelopper le log dans un try/catchbest-effort pour qu'un échec d'écriture ne puisse jamais faire échouer un paiement.
try {
Log::info("Webhook reçu ({$gatewayName})", ['payload' => $request->all()]);
} catch (\Throwable $logE) {
// silencieux — un échec de log ne doit jamais bloquer un paiement
}Ce que ça m'a appris
Le paiement exige un tempérament d'ingénierie spécifique. Les bugs intéressants ne sont pas sur les chemins heureux — ils sont dans les reprises, les courses et la dérive des fournisseurs. Trois règles que j'applique désormais partout :
lockForUpdate() + retour anticipé idempotent sur chaque changement d'état. 2. Ne laisse jamais un effet de bord faire échouer un paiement. Log, notifications ou appels secondaires doivent être best-effort, pas bloquants. 3. Traite les API de fournisseurs comme des contrats hostiles. Vérifie les signatures, tolère la fenêtre de rejeu, normalise les payloads multi-formats, et lis le code du SDK quand une requête renvoie un 401 cryptique.Pour une legaltech — où la confiance estle produit — l'escrow n'est pas une fonctionnalité. C'est tout l'enjeu. Et il ne tient que si le code en dessous refuse de laisser l'argent couler deux fois, ou de laisser une ligne de log faire tomber un paiement.
