

Adapter pattern, idempotence native, chiffrement bout en bout côté client, webhooks séparés et table d'audit append-only : le squelette pour intégrer six passerelles de paiement sans dette.
Intégrer plusieurs passerelles de paiement dans une plateforme fintech, c'est facile. Le faire sans incident en production, c'est une autre histoire. Voici le pattern que j'utilise.
Sur Nexus, la plateforme accepte plusieurs moyens de paiement : carte bancaire, mobile money (MTN, Orange, Moov), virement bancaire, parfois crypto. Chaque passerelle vit dans son écosystème : son SDK, son format de webhook, sa logique d'idempotence, son régime de pannes. Sans organisation claire, l'intégration finit en spaghetti de if imbriqués qu'aucun nouveau développeur ne comprend.
L'objectif de cet article : présenter le pattern qui m'a permis d'intégrer six passerelles distinctes sans dupliquer une seule ligne de logique métier, avec chiffrement bout en bout et idempotence native.
Première tentation : un gros service PaymentService avec un switch sur le moyen demandé. À 2 passerelles ça marche, à 6 c'est ingérable. Symptômes :
switch quand on a ajouté Mobile MoneyLa règle : un moyen de paiement = un module isolé, branché par contrat.
Le service paiement ne connaît qu'une interface : PaymentGateway. Chaque passerelle fournit son implémentation. Le routing est centralisé, le reste est polymorphe.
| 1 | // src/services/payment/types.ts |
| 2 | export type PaymentMethod = |
| 3 | | "card" |
| 4 | | "mobile_money_mtn" |
| 5 | | "mobile_money_orange" |
| 6 | | "mobile_money_moov" |
| 7 | | "bank_transfer" |
| 8 | | "crypto_usdc"; |
| 9 | |
| 10 | export interface PaymentInitiateInput { |
| 11 | amount: number; |
| 12 | currency: "XOF" | "USD" | "EUR"; |
| 13 | customerId: string; |
| 14 | method: PaymentMethod; |
| 15 | reference: string; // référence métier (no PII) |
| 16 | metadata?: Record<string, string>; |
| 17 | } |
| 18 | |
| 19 | export interface PaymentSession { |
| 20 | sessionId: string; |
| 21 | redirectUrl?: string; |
| 22 | expiresAt: Date; |
| 23 | } |
| 24 | |
| 25 | export interface PaymentGateway { |
| 26 | readonly name: string; |
| 27 | readonly supports: ReadonlyArray<PaymentMethod>; |
| 28 | initiate(input: PaymentInitiateInput): Promise<PaymentSession>; |
| 29 | confirm(sessionId: string, evidence: WebhookPayload): Promise<PaymentResult>; |
| 30 | refund(transactionId: string, amount: number): Promise<RefundResult>; |
| 31 | } |
Chaque adapter expose ses méthodes supportées via supports. Le router central choisit le bon en fonction de la méthode demandée :
| 1 | // src/services/payment/PaymentRouter.ts |
| 2 | import { PaymentGateway, PaymentMethod } from "./types.js"; |
| 3 | |
| 4 | export class PaymentRouter { |
| 5 | private readonly gateways = new Map<PaymentMethod, PaymentGateway>(); |
| 6 | |
| 7 | register(gateway: PaymentGateway) { |
| 8 | for (const method of gateway.supports) { |
| 9 | if (this.gateways.has(method)) { |
| 10 | throw new Error(`Method ${method} already registered by ${this.gateways.get(method)!.name}`); |
| 11 | } |
| 12 | this.gateways.set(method, gateway); |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | resolve(method: PaymentMethod): PaymentGateway { |
| 17 | const gateway = this.gateways.get(method); |
| 18 | if (!gateway) throw new Error(`No gateway registered for method ${method}`); |
| 19 | return gateway; |
| 20 | } |
| 21 | } |
Pour ajouter une 7ème passerelle : on écrit l'adapter, on l'enregistre au démarrage, le reste du code n'a pas à savoir qu'elle existe.
"Chiffrement E2E" est un mot abusé. Sur une plateforme de paiement, ça veut dire concrètement : les données sensibles (PAN, CVV, IBAN, numéro mobile) ne touchent jamais notre serveur en clair. Elles sont chiffrées côté client avec une clé publique de la passerelle, transmises chiffrées à travers notre backend, et déchiffrées seulement par la passerelle finale.
Côté frontend :
| 1 | // frontend/src/lib/payment/encryptPan.ts |
| 2 | export async function encryptPan(pan: string, gatewayPublicKeyPem: string): Promise<string> { |
| 3 | const key = await importPublicKey(gatewayPublicKeyPem); |
| 4 | const buffer = new TextEncoder().encode(pan); |
| 5 | const encrypted = await crypto.subtle.encrypt( |
| 6 | { name: "RSA-OAEP" }, |
| 7 | key, |
| 8 | buffer, |
| 9 | ); |
| 10 | return arrayBufferToBase64(encrypted); |
| 11 | } |
Côté backend, le PAN chiffré transite tel quel jusqu'à l'API de la passerelle. Aucun log, aucune persistance, aucun hash. Le seul élément qui reste de notre côté est un token opaque retourné par la passerelle, valide pour les remboursements futurs.
Cette approche élimine 80 % des risques PCI-DSS. Le serveur ne stocke pas de données carte, ne peut pas en émettre une copie, et ne peut pas être compromise pour fuiter des PAN.
Toute opération de paiement doit être idempotente. Le client doit pouvoir rejouer la même requête 200 fois sans créer 200 paiements. La discipline :
| 1 | // src/controllers/payment/initiatePayment.ts |
| 2 | export async function initiatePayment(req: Request, res: Response) { |
| 3 | const idempotencyKey = req.header("idempotency-key"); |
| 4 | if (!idempotencyKey) { |
| 5 | return res.status(400).json({ error: "idempotency-key header required" }); |
| 6 | } |
| 7 | |
| 8 | // 1. Existe-t-il déjà une session pour cette clé ? |
| 9 | const cached = await IdempotencyCache.findOne({ where: { key: idempotencyKey } }); |
| 10 | if (cached) { |
| 11 | return res.status(cached.statusCode).json(cached.response); |
| 12 | } |
| 13 | |
| 14 | // 2. Sinon, on traite la requête |
| 15 | const session = await paymentRouter |
| 16 | .resolve(req.body.method) |
| 17 | .initiate(req.body); |
| 18 | |
| 19 | const response = { sessionId: session.sessionId, redirectUrl: session.redirectUrl }; |
| 20 | |
| 21 | // 3. On cache la réponse pour 24 h |
| 22 | await IdempotencyCache.create({ |
| 23 | key: idempotencyKey, |
| 24 | statusCode: 201, |
| 25 | response, |
| 26 | expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), |
| 27 | }); |
| 28 | |
| 29 | res.status(201).json(response); |
| 30 | } |
Le client génère un UUID v7 (ordonnable temporellement) par tentative. S'il rejoue à cause d'une déconnexion, il renvoie la même clé. Le serveur retourne la même réponse. Pas de double paiement.
Chaque passerelle envoie ses webhooks à son rythme et avec son format. Le piège classique : tenter de tout normaliser dans un seul endpoint. Mauvaise idée. Préférer un endpoint par passerelle avec son propre validateur de signature.
| 1 | // src/routes/webhooks.ts |
| 2 | router.post("/webhooks/mtn", verifyMtnSignature, mtnWebhookHandler); |
| 3 | router.post("/webhooks/orange", verifyOrangeSignature, orangeWebhookHandler); |
| 4 | router.post("/webhooks/stripe", verifyStripeSignature, stripeWebhookHandler); |
Chaque handler convertit son format propriétaire en PaymentEvent normalisé, persiste l'event, accuse réception immédiatement (200 OK dans les 200 ms), et dispatch un job en queue pour le traitement réel.
| 1 | // src/middlewares/verifyStripeSignature.ts |
| 2 | import Stripe from "stripe"; |
| 3 | |
| 4 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); |
| 5 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; |
| 6 | |
| 7 | export function verifyStripeSignature(req: Request, res: Response, next: NextFunction) { |
| 8 | const signature = req.header("stripe-signature"); |
| 9 | if (!signature) return res.status(400).json({ error: "missing signature" }); |
| 10 | try { |
| 11 | const event = stripe.webhooks.constructEvent(req.rawBody, signature, webhookSecret); |
| 12 | req.body = event; // event vérifié |
| 13 | next(); |
| 14 | } catch (error) { |
| 15 | return res.status(400).json({ error: "invalid signature" }); |
| 16 | } |
| 17 | } |
Important : utiliser req.rawBody (avant parsing JSON) pour la vérification de signature — sinon ça casse à cause des espaces ajoutés/retirés par le parser.
Aucun webhook n'est garanti. Une passerelle peut tomber, votre serveur peut être hors ligne, le réseau peut perdre des paquets. La réconciliation périodique est obligatoire : un job qui interroge chaque passerelle toutes les 15 minutes pour récupérer les transactions des 24 dernières heures et croiser avec votre base.
| 1 | // src/jobs/reconcilePayments.ts |
| 2 | export async function reconcilePayments(gateway: PaymentGateway) { |
| 3 | const since = new Date(Date.now() - 24 * 60 * 60 * 1000); |
| 4 | const remoteTxs = await gateway.listTransactions({ since }); |
| 5 | |
| 6 | for (const remote of remoteTxs) { |
| 7 | const local = await Transaction.findOne({ |
| 8 | where: { gateway: gateway.name, externalId: remote.id }, |
| 9 | }); |
| 10 | |
| 11 | if (!local) { |
| 12 | logger.warn("Missing local transaction", { remote }); |
| 13 | await createTransactionFromRemote(gateway, remote); |
| 14 | } else if (local.status !== remote.status) { |
| 15 | logger.warn("Status mismatch", { local: local.status, remote: remote.status }); |
| 16 | await local.update({ status: remote.status, reconciledAt: new Date() }); |
| 17 | } |
| 18 | } |
| 19 | } |
La réconciliation a sauvé Nexus deux fois lors de coupures du fournisseur mobile money : les paiements confirmés mais non notifiés ont été récupérés sans intervention manuelle.
Toute opération financière doit être tracée. Pas pour le marketing, pour la conformité. À chaque mutation d'un solde ou d'une transaction, on écrit dans une table payment_audit :
| 1 | CREATE TABLE payment_audit ( |
| 2 | id UUID PRIMARY KEY, |
| 3 | transaction_id UUID NOT NULL, |
| 4 | actor_type VARCHAR(20) NOT NULL, -- 'user' | 'system' | 'admin' |
| 5 | actor_id UUID NULL, |
| 6 | action VARCHAR(40) NOT NULL, -- 'initiated' | 'confirmed' | 'refunded' | ... |
| 7 | before_state JSONB NULL, |
| 8 | after_state JSONB NOT NULL, |
| 9 | trace_id VARCHAR(64) NOT NULL, |
| 10 | at TIMESTAMP NOT NULL DEFAULT NOW() |
| 11 | ); |
| 12 | CREATE INDEX ON payment_audit (transaction_id, at); |
Cette table est append-only. Aucun UPDATE, aucun DELETE. Toute tentative est refusée par un trigger Postgres. C'est la base sur laquelle un auditeur reconstruit l'historique d'une transaction.
| Piège | Symptôme | Correction |
|---|---|---|
| Stocker le PAN même chiffré | Risque PCI-DSS, audit lourd | Chiffrement côté client, jamais touché côté serveur |
| Endpoint webhook unique | Couplage entre passerelles | Un endpoint par passerelle, signature spécifique |
Pas de idempotency-key | Doubles paiements en cas de retry | Header obligatoire, cache 24 h |
| Réconciliation manuelle | Transactions perdues lors de coupures | Job périodique de réconciliation |
| Audit dans la même table | Pollution + lock | Table payment_audit dédiée, append-only |
| Logs avec PAN | Fuite massive en cas de breach | Filtrage des champs sensibles avant log |
Le paiement multi-passerelles n'est pas un problème technique difficile. C'est un problème de discipline : isoler chaque passerelle, normaliser les events, idempotence partout, audit trail intangible, chiffrement bout en bout pour de vrai.
Si vous démarrez aujourd'hui, prenez ce squelette : PaymentGateway interface, PaymentRouter central, endpoints webhooks séparés, idempotency-key obligatoire, table d'audit append-only, job de réconciliation. Tout le reste est de la plomberie spécifique à chaque passerelle.
Si ce sujet ressemble à un problème réel dans votre produit, je peux intervenir sur le diagnostic, l'architecture, le backend, l'interface et les automatisations qui rendent une plateforme exploitable.
Réactions des lecteurs
Aucun commentaire pour l'instant
Soyez le premier à réagir à cet article.