Payments
Comment j'ai conçu un payment gateway orchestrator après un cours d'iGaming
PayMaestro : une seule API devant plusieurs payment gateways, avec du routing, des cascading retries, une idempotency garantie par le database, et un fraud screening basé sur une certification anti-fraude de l'iGaming Academy.
Après avoir terminé le cours Anti-Fraud and Payments Handling de l'iGaming Academy, je voulais vraiment construire quelque chose avec ce qu'il enseignait plutôt que de le laisser dormir comme un certificat. C'est devenu PayMaestro : une payment orchestration API, construite spec-first en un week-end.
Ce que orchestration signifie vraiment
Payment orchestration est la couche au-dessus des payment gateways. Un merchant s'intègre une fois, plusieurs routes d'acquiring se trouvent derrière cette seule intégration, et la orchestration platform ajoute l'intelligence : choisir la meilleure route pour chaque transaction, faire un retry en sécurité quand une route échoue, et garder une trace de tout ce qui s'est passé.
Cette dernière partie compte plus dans ce domaine que dans la plupart des autres. Les records de paiement sont des preuves réglementaires, donc le design doit les traiter comme telles dès le départ.
Hard decline et soft decline ne sont pas le même échec
C'est la distinction que le cours m'a rendue concrète, et c'est le cœur de la cascade policy. Un soft decline, fonds insuffisants ou un timeout, est récupérable, donc ça a du sens d'essayer le gateway suivant dans la route. Un hard decline signifie une carte volée ou bloquée, et réessayer sur un autre acquirer, ce n'est pas de la résilience, c'est juste essayer de faire passer une transaction frauduleuse ailleurs.
Donc la cascade s'arrête immédiatement sur un hard decline et continue sur un soft decline :
if (result.ResultType is GatewayResultType.Approved)
{
payment.Authorize();
payment.Capture();
return;
}
if (result.ResultType is GatewayResultType.HardDecline)
break; // fraud signal: never retry elsewhere
// SoftDecline / Error -> next gateway in the route
Fraud screening avant de contacter le moindre gateway
Chaque fraud rule implémente un seul Domain contract, donc en ajouter une ne touche pas l'orchestrator :
public interface IFraudRule
{
string RuleName { get; }
Task<FraudVerdict> EvaluateAsync(Payment payment, CancellationToken ct = default);
}
La première règle en production est decline velocity : une carte avec trois tentatives refusées ou plus en 24 heures est le pattern classique de card testing, donc la quatrième tentative est rejetée sans aucun appel à un gateway. Le screening tourne avant de contacter le moindre gateway, ce qui signifie qu'une carte déjà connue comme mauvaise ne coûte rien à rejeter.
L'idempotency appartient au database, pas seulement au code
Si un merchant répète un request, le même paiement ne doit jamais être facturé deux fois. L'approche évidente est de vérifier si l'idempotency key existe déjà avant d'insérer, mais ça a une fenêtre de race entre la lecture et l'écriture, et deux retries concurrents peuvent tous les deux passer la vérification.
Donc l'idempotency key a un index unique dessus. Le database sérialise les deux insertions, celle qui perd lève une erreur, et cette exception est capturée et répond en rejouant le résultat stocké. La garantie vit là où la concurrence est réellement résolue, plutôt que dans du code applicatif qui espère gagner une race.
Le routing comme configuration
L'éligibilité des gateways, quelles devises chacun supporte et quels plafonds de montant s'appliquent, plus la priorité des routes, vivent tous dans appsettings.json et sont liés via l'options pattern. Ajouter un acquirer, c'est une classe qui implémente le gateway contract et une entrée de config, sans changement à la moindre business logic.
Là où la clean architecture a vraiment payé
C'est le projet où le layering que j'apprenais a cessé d'être théorique. API → Application → Domain ← Infrastructure, avec le Domain qui porte les entities, la state machine des paiements, et tous les contracts, sans aucune référence externe. Infrastructure implémente ces contracts avec EF Core et SQLite.
La state machine des paiements est protégée à l'intérieur de l'aggregate lui-même, donc une transition invalide lève une domain exception au lieu de corrompre silencieusement un record. Il n'y a aucun chemin de retour depuis Captured. Mes projets précédents utilisaient un modèle anémique où l'entity n'était qu'un sac de propriétés et les règles vivaient ailleurs. Déplacer les invariants dans l'aggregate était un changement délibéré par rapport à ça.
Audit-first, et conscient du PCI
Chaque tentative de gateway est persistée avec le nom du gateway, l'ordre dans lequel elle a été essayée, le result code, et le temps que ça a pris. Chaque fraud flag est stockée aussi, et les delete sont restreints au niveau du database. Si un acquirer soulève une demande d'information, un seul GET renvoie le pack de preuves complet.
Seuls le BIN et les quatre derniers chiffres d'une carte sont jamais stockés, jamais le numéro complet, ce qui est le minimum de base pour manipuler des données de carte du tout.
Ce que j'ajouterais ensuite
D'autres règles sur le même IFraudRule contract : geo mismatch entre le pays de l'IP et le pays de la carte, qui a besoin de vraies données BIN et GeoIP pour être significatif, et la détection d'anomalie de montant. Puis les refunds, et le CI/CD vers Azure Container Apps.