Un système de paiement commence par un ledger, pas par une API
Pourquoi la comptabilité à double entrée n'est pas un détail d'implémentation quand on fait circuler l'argent des autres.
Quand on démarre un produit de paiement, la tentation est de commencer par ce qui se voit : l'écran d'envoi, le solde qui s'affiche, la notification de réception. C'est la partie qu'on montre. Ce n'est pas la partie qui compte.
Ce qui compte, c'est la question à laquelle il faut pouvoir répondre à n'importe quel moment, sans hésiter : où est chaque centime, et comment est-il arrivé là ?
Le solde n'est pas une donnée
La première erreur, je l'ai vue plusieurs fois : une table wallets avec une colonne balance, et des fonctions qui l'incrémentent ou la décrémentent. C'est simple, rapide, et faux.
Faux parce qu'un solde stocké est une affirmation sans preuve. Si deux transactions l'ont modifié en même temps, si une mise à jour a échoué à mi-chemin, si quelqu'un l'a corrigé à la main un vendredi soir — rien dans la donnée ne permet de le savoir.
Dans un ledger à double entrée, le solde n'est pas stocké : il est calculé comme la somme des écritures d'un compte. Chaque mouvement d'argent produit au moins deux écritures, une au débit et une au crédit, dont la somme est nulle. On ne peut pas créer d'argent, on ne peut pas en perdre. Une transaction qui ne s'équilibre pas est rejetée par la base, pas par une vérification applicative que quelqu'un finira par contourner.
Ce que ça change dans le code
Concrètement, le cœur du système tient en trois tables : les comptes, les transactions, les écritures. Tout le reste — wallets, transferts, change, frais — est une façon de produire des écritures.
-- Une transaction est valide si et seulement si ses écritures s'équilibrent.
create table entries (
id uuid primary key,
transaction_id uuid not null references transactions(id),
account_id uuid not null references accounts(id),
amount numeric(20, 4) not null, -- positif au crédit, négatif au débit
currency char(3) not null
);
create or replace function assert_balanced(tx uuid) returns void as $$
begin
if (select sum(amount) from entries where transaction_id = tx) <> 0 then
raise exception 'transaction % is not balanced', tx;
end if;
end;
$$ language plpgsql;La contrainte vit dans la base parce que c'est le seul endroit que tous les chemins de code traversent. Un service oublié, un script de migration, une correction manuelle : tout passe par là.
Les frais, le change, les erreurs
Un transfert Mobile Money vers un compte bancaire dans une autre devise, avec des frais, ce n'est pas une écriture : c'en est six. Le débit du client, le crédit du compte de transit, les frais vers le compte de revenus, la conversion avec le compte de change, le crédit final.
Ça paraît lourd. C'est exactement l'inverse. Quand un client demande pourquoi il a reçu 4 950 au lieu de 5 000, la réponse est dans les écritures, ligne par ligne, avec l'horodatage et le taux appliqué. Sans ledger, la réponse est « on va regarder », et « on va regarder » est ce qui fait perdre un client dans le paiement.
Et quand une opération échoue à mi-chemin, on ne « corrige » pas : on passe une transaction inverse. Rien n'est jamais supprimé. L'historique est la donnée.
La conformité vit au même endroit
KYC, plafonds, listes de sanctions : on a souvent envie de les mettre « devant », dans une couche de validation. Je les mets dans le moteur transactionnel, comme une condition d'acceptation de la transaction, au même niveau que l'équilibre des écritures.
La raison est la même : c'est le seul endroit que rien ne contourne. Une règle de conformité en périphérie finit par avoir une exception « pour l'admin », puis « pour les tests », puis pour personne ne sait plus qui.
Ce que ça coûte
Du temps au départ. Le ledger est la première brique de Nexus Pay et elle a pris plus longtemps que l'écran d'envoi n'en prendra jamais. Mais c'est la brique qu'on ne réécrit pas : tout ce qui vient après — rails de paiement, devises, régulateurs — se branche dessus sans la modifier.
Dans un système de paiement, la seule question qu'on se pose vraiment est celle de la confiance. Et la confiance ne se déclare pas dans une interface. Elle se prouve dans les écritures.
À lire ensuite
Une seule dépendance dure : une chaîne de connexion PostgreSQL
Comment on a conçu une plateforme ambitieuse pour qu'elle démarre sur un serveur modeste, sans Docker, sans Redis, sans file d'attente.
Isoler les données de plusieurs entreprises dans une seule base
Row Level Security dans PostgreSQL : le piège du rôle superutilisateur, et pourquoi le filtre applicatif ne suffit pas.
FastAPI en production : ce qui compte vraiment
Cinq réglages qu'on ne voit dans aucun tutoriel et qui font la différence entre une API qui tourne et une API qu'on peut exploiter.