Concevoir une architecture de services Rails autour de SEPA et des flux de paiement
Les paiements changent les questions de conception auxquelles une codebase Rails doit répondre. Notes sur l'isolation des mouvements d'argent, les ledgers, et la modélisation de SEPA comme le processus asynchrone qu'il est.
Publié le 8 février 2026 · 5 min de lecture
Les paiements sont la seule partie d'une codebase Rails où « il suffit d'ajouter un modèle » ne fonctionne plus. Dès que de l'argent réel circule, les questions de conception changent : que se passe-t-il si le webhook de la banque n'arrive jamais, que se passe-t-il s'il arrive deux fois, et qui a le droit de lire un solde.
Isoler les mouvements d'argent du reste de l'application
Nous extrayons systématiquement la logique de paiement dans son propre service interne, même au sein d'un monolithe, avec ses propres modèles et ses propres tables clairement préfixées par un namespace. Le reste de l'application lui parle à travers une interface réduite et explicite : initier un virement, obtenir le statut courant. Rien en dehors de cette frontière n'écrit directement sur un solde.
Un ledger en ajout seul, pas une colonne de solde modifiable
Une colonne de solde incrémentée et décrémentée sur place finira par diverger de la réalité, généralement découverte lors d'un rapprochement, généralement au pire moment. Nous utilisons à la place un ledger en ajout seul composé d'entrées individuelles, et nous calculons le solde en les additionnant. Cela coûte une requête ; cela offre une piste d'audit complète et rejouable pour chaque euro qui a bougé.
class LedgerEntry < ApplicationRecord
belongs_to :account
validates :amount_cents, presence: true
# les entrées sont créées, jamais modifiées ni supprimées
end
def balance_cents(account)
account.ledger_entries.sum(:amount_cents)
end
SEPA est asynchrone par nature, modélisez-le ainsi
Un virement SEPA n'est pas un simple appel d'API avec une réponse immédiate oui ou non. Il traverse des états sur plusieurs heures : soumis, accepté par la banque, réglé, ou rejeté plusieurs jours plus tard pour des raisons indépendantes de votre volonté. Nous le modélisons explicitement comme une machine à états avec un petit ensemble fixe de transitions, pilotée par des webhooks, et nous rendons chaque transition idempotente, car les partenaires bancaires finiront, tôt ou tard, par envoyer deux fois le même webhook.
Le rapprochement est une fonctionnalité, pas une réflexion tardive
Toutes les architectures de paiement que nous avons construites incluent un job planifié qui compare le ledger interne au relevé du partenaire bancaire et lève une alerte sur le moindre écart, aussi petit soit-il. La première fois qu'il détecte quelque chose, c'est généralement dans le premier mois, et c'est généralement une race condition que personne n'avait anticipée lors de la conception.
Articles liés
Idempotence par conception : rendre vos jobs Sidekiq sûrs à rejouer
Sidekiq garantit une livraison au moins une fois (at-least-once), pas exactement une fois. Tout job qui n'est pas sûr à exécuter deux fois est un bug qui attend le bon chevauchement.
Le pattern outbox dans Rails : publier des événements sans perdre de données
L'écart entre l'enregistrement d'une donnée et la publication du message qui l'annonce est l'endroit où les événements disparaissent discrètement. Voici comment le combler.