Système de transactions NxPay — Architecture, sécurité et gouvernance
Version : 1.0 Date : 2026-04-17 Périmètre : plateforme NxPay, zone CEMAC (Cameroun, Congo, Gabon, Guinée équatoriale, Tchad, République centrafricaine). Devises XAF et XOF. Destinataires : direction, département compliance, organes de régulation (COBAC), équipe plateforme. Propriétaire : équipe plateforme NxPay. Statut de validation : document de référence à faire approuver par Tech Lead backend + Responsable Produit/Compliance.
Sommaire
- Synthèse exécutive
- Cadre réglementaire et enjeux compliance
- Fonctionnement métier des transactions
- Principes de conception du système
- Architecture technique
- Protection contre le double-spend
- Procédures de gestion d'une transaction
- Audit et contrôles continus
- Gouvernance et rôles
- Limites connues et feuille de route
- Références
- Annexe A — Glossaire
1. Synthèse exécutive
NxPay est une plateforme de paiement mobile opérant dans la zone CEMAC. Son cœur métier est la gestion sécurisée de transactions financières entre utilisateurs, agents et organisations, en XAF et XOF, à travers trois flux principaux : transfert entre particuliers (P2P), dépôt d'argent liquide (cash-in), retrait d'argent liquide (cash-out).
Jusqu'au premier trimestre 2026, un audit interne a révélé l'existence d'écarts comptables silencieux — jusqu'à 317 626 XAF de différence entre le solde affiché d'un compte et la somme des écritures du grand livre associé. Ces écarts, non frauduleux, résultaient d'un défaut de conception dans la coordination des opérations simultanées (phénomène dit de double-spend).
Un chantier complet de durcissement a été mené (référence interne : ticket NEX-180). Il a livré :
- Une refonte du chemin critique de paiement pour le rendre strictement atomique au niveau de la base de données.
- Des contraintes d'intégrité infalsifiables au niveau du stockage.
- Un mécanisme d'idempotence transversal protégeant contre les rejeux.
- Une piste d'audit enrichie et un contrôle d'intégrité nocturne.
État au 17 avril 2026 :
- Le chemin de paiement canonique (transfert entre particuliers, dépôt et retrait agent) est désormais protégé contre le double-spend et l'altération comptable.
- Les cas résiduels (recharge mobile, paiements en masse, rappels trésorerie) restent à aligner sur le nouveau modèle. Ils font l'objet de tickets de suivi dédiés.
- Le système est conforme, sur son périmètre couvert, aux principes comptables attendus d'un établissement de monnaie électronique : immutabilité du grand livre, double entrée systématique, traçabilité individuelle des opérations, séparation structurelle des comptes techniques.
Restent à traiter, en dehors du périmètre NEX-180 : la mise en place du cantonnement bancaire et de la réconciliation banque-ledger, qui dépendent du statut EME officiel de NxPay. Ce sujet fait l'objet d'un chantier distinct.
2. Cadre réglementaire et enjeux compliance
2.1 Réglementation applicable
En zone CEMAC, les établissements émetteurs de monnaie électronique sont soumis aux dispositions de la Commission Bancaire d'Afrique Centrale (COBAC) et aux textes de la BEAC. Les exigences structurantes pour un système de comptabilité transactionnelle sont :
- Comptabilité en partie double : toute écriture au crédit d'un compte doit être compensée par une écriture au débit symétrique.
- Immutabilité du grand livre : une fois enregistrée, une écriture ne peut être ni modifiée ni supprimée. Les corrections se font par écriture inverse.
- Piste d'audit complète : chaque opération est tracée avec son auteur, son horodatage, sa référence unique, et conserve ses attributs d'origine.
- Séparation des tâches (segregation of duties) : les opérations sensibles requièrent une séparation entre l'initiateur et l'approbateur.
- Cantonnement : les fonds des clients doivent être ségrégés des fonds propres de l'émetteur, sur un compte bancaire distinct.
2.2 Enjeux opérationnels
Un écart entre le grand livre interne et les soldes affichés aux clients constitue un risque à quatre niveaux :
- Financier : la plateforme peut débourser, vers une banque ou un agent, des montants supérieurs à ceux réellement comptabilisés. Perte sèche.
- Compliance : un écart non expliqué est un manquement COBAC grave.
- Réputationnel : la perte de confiance d'un régulateur ou d'un utilisateur se reconstruit lentement.
- Pénal : les responsables peuvent être mis en cause pour défaut de contrôle interne.
Le chantier NEX-180 ferme la principale porte d'entrée à ce risque.
3. Fonctionnement métier des transactions
3.1 Types de transactions
NxPay opère trois types de transactions entre utilisateurs finaux :
| Type | Description | Acteurs | Devise typique |
|---|---|---|---|
| P2P (Transfer) | Transfert d'argent d'un utilisateur vers un autre utilisateur | Émetteur + Récepteur | XAF/XOF |
| Cash-in | L'agent dépose de l'argent liquide sur le compte d'un client | Agent + Client | XAF/XOF |
| Cash-out | L'agent remet de l'argent liquide au client (retrait du compte) | Agent + Client | XAF/XOF |
À ces flux s'ajoutent des opérations périphériques (recharge mobile money, paiements en masse, distribution de commission, rappel de fonds en trésorerie, destruction de monnaie électronique), traitées par des flux techniques dédiés.
3.2 Acteurs
Les acteurs du système transactionnel sont :
- Client (utilisateur final) : personne physique disposant d'un compte NxPay, initiatrice ou réceptrice d'un paiement.
- Agent : personne ou organisation partenaire qui accueille le client physiquement, assure les dépôts et retraits d'espèces. Catégorie distinguée en Agent simple et Master Agent (affiliée à une organisation).
- Master Agent / Organisation : entité légale regroupant plusieurs agents sous son autorité, disposant de son propre portefeuille corporate.
- Système (plateforme) : NxPay elle-même, qui opère les comptes techniques (trésorerie, collecte de frais, compte suspense, compte black pour monnaie détruite).
- Opérateur compliance / finance : humain habilité à auditer les écarts, approuver les opérations sensibles, initier les remédiations manuelles.
3.3 Cycle de vie d'une transaction
Toute transaction financière NxPay suit désormais le cycle de vie d'un intent. L'intent est un objet métier représentant l'intention d'exécuter une transaction, porteur de tous ses paramètres, qui progresse à travers une machine à états rigoureusement contrôlée :
┌─────────┐
│ CREATED │ ─────┐
└────┬────┘ │
│ │
┌─────────┼──────────┐│
▼ ▼ ▼▼
┌──────────┐┌───────┐┌────────┐
│ OTP_SENT ││ ... ││ FAILED │
└────┬─────┘└───┬───┘│EXPIRED │
│ │ │CANCELED│
▼ │ └────────┘
┌──────────────┐│
│ OTP_VERIFIED ││
└──────┬───────┘│
│ │
▼ ▼
┌──────────────┐
│ PIN_VERIFIED │
└──────┬───────┘
│ (transition atomique, voir §6)
▼
┌───────────┐
│ SUCCEEDED │ (état terminal)
└───────────┘États intermédiaires (chemins) :
CREATED: intent créé, destinataire résolu, montant figé, clé d'idempotence posée.OTP_SENT: un code à usage unique a été envoyé au client (cas cash-out avec QR non frais).OTP_VERIFIED: le client a validé son OTP.PIN_VERIFIED: le PIN de l'initiateur (agent ou utilisateur) a été validé. L'intent est prêt à être exécuté.
États terminaux :
SUCCEEDED: la transaction financière est enregistrée et les soldes sont mis à jour.FAILED: une erreur bloquante a interrompu l'exécution.EXPIRED: le délai de vie de l'intent (10 minutes par défaut, configurable) est dépassé.CANCELED: l'intent a été explicitement annulé par son initiateur.
Une fois un état terminal atteint, aucune transition n'est possible. Les états ne sont jamais "effacés" : ils constituent la trace historique de l'intent.
3.4 Événements d'intent
Chaque transition d'état produit un enregistrement dans la table intent_events. Cet enregistrement contient :
- L'intent concerné
- L'état source et l'état cible
- Le nom de l'événement métier (
pin_ok,otp_sent,transaction_completed, etc.) - L'acteur qui a initié la transition (identifiant utilisateur)
- L'adresse IP et l'user-agent du client
- Les métadonnées associées (référence transaction, raison d'échec)
Cette table est append-only : aucune écriture ne peut y être modifiée ou supprimée.
4. Principes de conception du système
4.1 Grand livre en partie double
Toute modification de solde d'un compte client est systématiquement accompagnée d'une ou plusieurs écritures dans la table ledgers. Les écritures sont jumelées :
- Un paiement P2P de 1 000 XAF du compte A vers le compte B produit deux écritures liées : un
DEBITde 1 000 sur A et unCREDITde 1 000 sur B. Les deux écritures se référencent l'une l'autre via leurcounterparty_ledger_id. - Si la transaction comporte des frais de 50 XAF, deux écritures supplémentaires sont produites : un
DEBITde 50 sur A et unCREDITde 50 sur le compte techniqueFEE_COLLECTION. - Si la transaction génère une commission agent, deux écritures supplémentaires encore : un
DEBITsur le compte techniqueTREASURYet unCREDITsur le compte commission de l'agent.
À tout instant, l'invariant fondamental doit être vérifié pour chaque compte :
accounts.balance == SUM(credits dans ledgers) - SUM(debits dans ledgers)Cet invariant est vérifié quotidiennement par un contrôle automatique (voir §8.3).
4.2 Immutabilité du grand livre
La table ledgers est protégée par un trigger PostgreSQL qui rejette toute tentative de UPDATE ou DELETE sur ses lignes, quelle que soit l'origine de la commande (code applicatif, console d'administration, requête manuelle). Le message d'erreur renvoyé est :
ledger_wallet.ledgers is append-only: UPDATE operation forbidden on id <uuid>Cette protection est inscrite au niveau de la base de données elle-même (migration 021-ledgers-append-only.sql). Elle ne peut pas être contournée par un développeur distrait ou un opérateur hostile — seule une opération DBA explicite (drop du trigger) pourrait la désactiver, et cette opération laisse elle-même une trace dans les logs PostgreSQL.
4.3 Piste d'audit complète
Chaque entité transactionnelle porte des attributs d'audit :
transactions.created_by: identifiant de l'utilisateur (humain ou système) à l'origine de l'opération.transactions.initiated_at/completed_at: horodatages précis.transactions.reference_id: identifiant unique lisible (formatNXPTXYYMMDDXXXXXXX), permettant la recherche transverse.ledgers.created_by: identifiant de l'initiateur, systématiquement peuplé pour les transactions issues du flow intent.intent_events: journal complet de chaque changement d'état, horodaté et acteur-isé.- Métadonnées jsonb : contexte additionnel (code type de transaction, identifiant d'agent, identifiant de corrélation inter-services).
4.4 Idempotence des mutations financières
Chaque requête de création ou d'exécution de transaction porte une clé d'idempotence fournie par le client. Cette clé est :
- Obligatoire à la création d'un intent (champ
idempotencyKeydu corps HTTP) : empêche la création de deux intents identiques en cas de double-soumission. - Optionnelle mais fortement recommandée sur la transition
SUCCEEDED(header HTTPX-Idempotency-Key) : empêche l'exécution de deux paiements pour la même intention.
Le mécanisme est fondé sur le pattern Stripe :
- Le client génère un UUID v4 unique pour une opération donnée.
- Le serveur calcule un hash SHA-256 d'une version canonicalisée du corps de la requête (tri alphabétique des clés, suppression des champs secrets comme PIN et OTP, suppression des champs volatils comme horodatages).
- Si la clé existe déjà avec le même hash → le serveur renvoie à l'identique la réponse mémorisée, sans rejouer l'opération.
- Si la clé existe avec un hash différent → le serveur rejette la requête avec une erreur de conflit (HTTP 422).
- Si la clé n'existe pas → l'opération est exécutée, puis la clé, le hash et la réponse sont persistés avec une durée de validité de 48 heures.
4.5 Intégrité concurrente
Dans un environnement distribué, deux requêtes peuvent arriver au même instant sur le même compte. Sans précaution, elles peuvent lire le solde simultanément, chacune valider qu'elle dispose de suffisamment de fonds, puis chacune débiter — alors qu'il n'y avait l'argent que pour une. Ce phénomène, dit double-spend, a été identifié comme la cause racine des écarts constatés.
Le système empile quatre mécanismes pour l'éliminer (détaillé au §6) :
- Verrou pessimiste exclusif au niveau ligne de base de données.
- Ordre canonique des verrouillages pour éviter les interblocages (deadlocks).
- Isolation transactionnelle serializable.
- Contraintes de base de données infalsifiables comme filet ultime.
5. Architecture technique
5.1 Services et responsabilités
Le domaine transactionnel repose sur deux services distincts :
ledger-wallets (port 3002 en environnement local) : source de vérité financière.
- Héberge les entités
wallets,accounts,transactions,ledgers,transaction_intents,intent_events,idempotency_keys,balance_integrity_checks. - N'expose aucun endpoint public. Toutes ses routes sont préfixées
/v1/internal/et ne sont accessibles qu'en réseau privé. - Porte la logique d'atomicité (transactions SQL, verrous pessimistes, contraintes d'intégrité).
orchestrator (port 3004 en environnement local) : passerelle métier et couche applicative.
- Expose les endpoints publics à destination des clients (mobile, web).
- Coordonne les appels entre services (
auth,customer-profiles-kyc,risk-engine,configuration,notifications,ledger-wallets,providers-gateway). - Applique les règles métier de validation (minimum, maximum, plafonds agents, restrictions).
- Résout les destinataires (par numéro de téléphone, par QR code).
- N'effectue aucune écriture directe en base
ledger-wallets— la délégation se fait exclusivement par HTTP REST.
5.2 Modèle de données
Base de données : PostgreSQL 15+, schéma dédié ledger_wallet.
| Table | Rôle | Volumétrie attendue |
|---|---|---|
wallets | Un par entité active (utilisateur, agent, organisation, système) | 1 / utilisateur |
accounts | Multi-devises par wallet, porte le solde (balance, available_balance, frozen_balance) | 1 à 3 / wallet |
transactions | Chaque opération financière enregistrée | croissante |
ledgers | Écritures comptables individuelles (debit et credit), append-only | 2 à 6 par transaction |
transaction_intents | Cycle de vie des intents | 1 / transaction |
intent_events | Journal des transitions d'état, append-only | 2 à 8 / intent |
idempotency_keys | Stockage des clés d'idempotence, TTL 48h | transitoire |
balance_integrity_checks | Historique des contrôles d'intégrité nocturnes | rétention indéfinie |
Contraintes clés sur accounts :
balance = available_balance + frozen_balance: cohérence interne du solde.balance >= 0 OR account_type = 'treasury': pas de compte client en négatif. Les comptes trésorerie peuvent temporairement être en négatif (prélèvement avant réapprovisionnement).available_balance >= 0etfrozen_balance >= 0: sous-soldes toujours positifs.
Contraintes clés sur ledgers :
- Foreign key
transaction_idverstransactions(on delete cascade). - Trigger BEFORE UPDATE / DELETE qui rejette toute mutation.
Contraintes clés sur transaction_intents :
- Index unique sur
idempotency_key: une clé n'est utilisable qu'une fois. - Index unique partiel sur
qr_token(pour les intents actifs uniquement) : un QR code ne peut verrouiller qu'un seul intent à la fois.
5.3 Flux d'une transaction — séquence détaillée
Le chemin canonique d'un paiement P2P (transfert de 1 000 XAF entre utilisateurs) comporte 6 appels HTTP côté client mobile :
Client Mobile Orchestrator ledger-wallets
│ │ │
1. │ POST /intents │ │
├──────────────────────▶│ │
│ │ POST /internal/intents │
│ ├────────────────────────▶│
│ │ │ (crée intent + lock QR)
│ │◀────────────────────────┤
│◀──────────────────────┤ 201 { intentId, requiresOtp }
│ │ │
2. │ POST /intents/:id/ │ │
│ verify-pin │ │
├──────────────────────▶│ │
│ │ ... (vérif PIN auth) │
│ │ POST /transition │
│ ├────────────────────────▶│ (pin_verified)
│ │◀────────────────────────┤
│◀──────────────────────┤ │
│ │ │
3. │ POST /intents/:id/ │ │
│ confirm │ │
├──────────────────────▶│ │
│ │ (résolution comptes + │
│ │ validations métier + │
│ │ risk engine) │
│ │ │
│ │ POST /transition │
│ │ SUCCEEDED + │
│ │ executionContext │
│ ├────────────────────────▶│
│ │ │ ┌─────────────────────┐
│ │ │ │ TRANSACTION SQL │
│ │ │ │ SERIALIZABLE │
│ │ │ │ │
│ │ │ │ 1. Lock intent │
│ │ │ │ 2. Lock accounts │
│ │ │ │ (ordre ASC) │
│ │ │ │ 3. Check balance │
│ │ │ │ 4. Create transac. │
│ │ │ │ 5. Create ledgers │
│ │ │ │ (double-entry) │
│ │ │ │ 6. Update balances │
│ │ │ │ 7. Update intent │
│ │ │ │ 8. Log event │
│ │ │ │ │
│ │ │ │ COMMIT (atomic) │
│ │ │ └─────────────────────┘
│ │◀────────────────────────┤
│◀──────────────────────┤ 200 { transactionId, referenceId }Pour un cash-out nécessitant un OTP (cas QR non frais), deux appels supplémentaires s'intercalent : send-otp puis verify-otp.
La phase 3 (confirmation) est le moment critique : elle est strictement atomique côté base de données. Toutes ses sous-étapes (1 à 8) réussissent ensemble ou échouent ensemble. Aucun état intermédiaire n'est observable.
6. Protection contre le double-spend
6.1 Le problème
Sans protection, le scénario suivant est possible :
- Un compte A a un solde de 1 000 XAF.
- Deux requêtes de retrait de 800 XAF arrivent au même instant (par exemple : l'utilisateur double-clique sur son bouton, ou le réseau rejoue une requête après un timeout).
- Requête 1 lit le solde (1 000), valide le retrait (1 000 ≥ 800), enregistre le débit.
- Requête 2, arrivée en parallèle, lit elle aussi le solde avant la mise à jour de la requête 1 (1 000), valide également, enregistre un second débit.
- Résultat : deux retraits de 800 XAF passent. L'argent réel (1 600 XAF) a quitté le système. Le solde affiché devient 200 (au lieu de -600).
C'est précisément ce type d'écart qui a été observé en production sur l'environnement de review, avec des différences allant jusqu'à 317 626 XAF.
6.2 Les quatre couches de défense
Le système empile quatre mécanismes. Chacun est indépendant des autres et constitue un filet de sécurité. Il faut que les quatre soient défaillants simultanément pour qu'un double-spend soit possible.
Couche 1 — Verrou pessimiste au niveau ligne
Lors de la confirmation d'une transaction, le service ledger-wallets pose un verrou exclusif sur les lignes de la base concernées, via la clause SQL SELECT ... FOR UPDATE. PostgreSQL garantit qu'une autre transaction qui tente le même SELECT FOR UPDATE attend que la première commit ou rollback avant de pouvoir lire la ligne.
Concrètement :
BEGIN;
SELECT * FROM accounts WHERE id = '<source>' FOR UPDATE;
-- À cet instant, cette ligne est verrouillée.
-- Une transaction concurrente qui fait le même SELECT attend.
...
COMMIT;
-- Le verrou est relâché, les transactions en attente peuvent procéder.La vérification de solde s'effectue après le verrou. La transaction concurrente, lorsqu'elle est enfin exécutée, lit le solde post-commit de la première, et constate que le montant est insuffisant — elle est rejetée proprement avec un code HTTP 422.
Couche 2 — Ordre canonique des verrouillages
Dans un transfert, deux comptes sont impliqués (source et destination). Deux transactions concurrentes verrouillant les comptes dans des ordres différents provoqueraient un interblocage (deadlock) : A attend B, B attend A.
Le système impose que les comptes soient toujours verrouillés dans le même ordre canonique : tri ascendant par identifiant (UUID). Deux transactions sur les mêmes comptes demandent donc les verrous dans le même ordre, et l'une attend simplement que l'autre termine. Aucun interblocage n'est possible par construction.
Couche 3 — Isolation SERIALIZABLE
Le verrou au niveau ligne ne protège pas contre tous les types de conflits (lectures de plage, conditions dépendant de l'état global). Le niveau d'isolation PostgreSQL SERIALIZABLE force le système à garantir que le résultat net de la transaction est équivalent à une exécution séquentielle de toutes les transactions concurrentes.
Si PostgreSQL détecte qu'une telle équivalence est impossible, il rejette une des transactions avec l'erreur serialization_failure (SQLSTATE 40001). Le service ledger-wallets intercepte cette erreur et rejoue automatiquement la transaction jusqu'à trois fois, avec un délai exponentiel (100 ms, 400 ms, 1 600 ms). Au-delà, l'opération est remontée au client comme un conflit temporaire (HTTP 409).
Un délai d'attente maximal sur les verrous est également posé (SET LOCAL lock_timeout = '3s') pour éviter qu'une transaction ne bloque le service indéfiniment. Au-delà de 3 secondes d'attente, l'erreur lock_not_available (SQLSTATE 55P03) est levée et traitée de la même façon qu'un conflit de sérialisation.
Couche 4 — Contraintes d'intégrité de base de données
Ultime filet de sécurité, indépendant du code applicatif : les contraintes PostgreSQL rejettent toute tentative de violation, même en cas de bug logiciel :
CHK_accounts_positive_balance: un compte non-trésorerie ne peut pas avoir un solde négatif.CHK_accounts_balance_consistency: le solde total d'un compte est toujours la somme de son solde disponible et de son solde gelé.CHK_accounts_positive_available_balanceetCHK_accounts_positive_frozen_balance: sous-soldes positifs.- Trigger
trg_ledgers_append_only: toute tentative d'UPDATE ou DELETE sur la tableledgerslève une exception.
6.3 Garanties fournies
Avec ces quatre couches :
- Un double-spend par concurrence est rejeté au niveau applicatif (couches 1 + 2 + 3) avec un code d'erreur explicite.
- Un double-spend par bug ou par manipulation directe est rejeté au niveau base de données (couche 4), avec une exception tracée.
- Un rejeu de requête par retry réseau est absorbé par l'idempotence (retour de la réponse mémorisée, aucun second débit).
- Une tentative de modification rétroactive du grand livre est impossible sans intervention DBA sur le trigger, qui laisse une trace système.
Le scénario où deux retraits simultanés de 800 XAF sur un compte à 1 000 XAF se termine aujourd'hui par :
- Un retrait de 800 qui réussit (HTTP 200).
- Un retrait qui échoue avec un message clair : solde disponible 200, requis 800 (HTTP 422).
- Un solde final de 200 XAF, exactement cohérent avec les écritures du grand livre.
7. Procédures de gestion d'une transaction
7.1 La règle d'or : un unique chemin d'écriture
Toute transaction financière NxPay doit être initiée par la création d'un intent.
Cette règle est constitutive de la nouvelle architecture. Elle implique :
- Aucun autre endpoint que le flow intent ne doit être utilisé pour créer de nouveau paiement.
- Les endpoints directs historiques (
POST /v1/transactionset ses dérivés) sont en cours de dépréciation. Ils continuent de fonctionner mais seront supprimés dans une itération ultérieure. - Toute nouvelle fonctionnalité transactionnelle se branche sur le flow intent en créant une nouvelle stratégie de confirmation dédiée.
7.2 Étape par étape pour un implémenteur
1. Créer l'intent
Le client envoie POST /v1/intents avec un corps contenant au minimum :
{
"type": "TRANSFER" | "CASH_IN" | "CASH_OUT",
"amount": <nombre entier en XAF>,
"idempotencyKey": "<UUID v4 généré côté client>",
"clientPhone": "<numéro destinataire>",
"clientCountryCode": "<préfixe pays>",
"description": "<libellé optionnel>"
}Le serveur :
- Valide la structure du corps.
- Résout le destinataire.
- Évalue le risque auprès du
risk-engine. - Persiste l'intent avec un verrou d'unicité sur
idempotencyKey.
Réponse : { intentId, status: "CREATED", requiresOtp, expiresAt }.
2. (Si OTP requis) Envoyer et vérifier l'OTP
POST /v1/intents/:id/send-otp puis POST /v1/intents/:id/verify-otp avec { otpCode }. Transitions internes : OTP_SENT → OTP_VERIFIED.
3. Vérifier le PIN de l'initiateur
POST /v1/intents/:id/verify-pin avec { pin }. L'orchestrator valide le PIN auprès du service auth. Transition : PIN_VERIFIED.
4. Confirmer et exécuter
POST /v1/intents/:id/confirm. L'orchestrator :
- Recalcule et valide les frais.
- Évalue le risque une seconde fois (confirmation).
- Construit l'
executionContext(comptes, montants, frais, commission, métadonnées). - Appelle en un seul appel HTTP le ledger :
POST /v1/internal/intents/:id/transitionavectoStatus: SUCCEEDEDet le contexte complet. - Le ledger exécute alors la transaction atomique décrite au §5.3.
Réponse : { intentId, status: "SUCCEEDED", transactionId, referenceId, amount, fees }.
7.3 Gestion des erreurs
Tous les cas d'erreur sont mappés à des codes HTTP explicites et des exceptions de domaine nommées :
| Situation | Exception (domaine) | Code HTTP |
|---|---|---|
| Solde insuffisant | InsufficientBalanceException | 422 |
| Conflit de concurrence non résolu après retries | ConcurrencyConflictException | 409 |
| Idempotency-Key réutilisée avec corps différent | IdempotencyKeyConflictException | 422 |
| Idempotency-Key manquante sur endpoint obligatoire | IdempotencyKeyMissingException | 400 |
| Violation d'intégrité du grand livre | LedgerIntegrityViolationException | 500 (+ alerte compliance) |
| Transition d'intent invalide | BadRequestException | 400 |
| Intent introuvable | NotFoundException | 404 |
Chaque erreur contient un code métier stable (par exemple INSUFFICIENT_BALANCE, CONCURRENCY_CONFLICT) qui permet au client de traiter le cas de façon structurée, plutôt que de parser un message texte.
7.4 Tests et preuves
La robustesse du système est garantie par trois niveaux de tests automatisés :
- Tests unitaires : couvrent chaque méthode métier en isolation avec des doublures de test. Au 17 avril 2026, 2 170 tests sont verts (ledger-wallets 787, orchestrator 1 383).
- Tests d'intégration (à venir en MR5) : utilisent une vraie base PostgreSQL éphémère via Testcontainers. Couvrent les contraintes DB, le trigger append-only, les cas de concurrence réelle avec
Promise.all. - Tests de concurrence (à venir en MR5) : reproduisent les scénarios de double-spend avec des requêtes simultanées sur la même ressource. Chaque scénario est exécuté 20 fois pour prouver l'absence de comportement aléatoire.
Le plan de tests complet, organisé en onze sections et une centaine de cas, est maintenu dans le document _bmad-output/implementation-artifacts/NEX-180-test-plan.md.
8. Audit et contrôles continus
8.1 Contrôles au niveau base de données
Les contraintes PostgreSQL détaillées au §4 sont actives à chaque écriture. Toute violation est rejetée immédiatement, avec un code d'erreur standardisé. Les contraintes d'intégrité ne peuvent pas être contournées par le code applicatif.
8.2 Traçabilité applicative
Chaque opération critique produit un journal structuré contenant :
- Un identifiant de corrélation (
correlationId), UUID v4 généré par l'orchestrator, propagé vers le ledger via header HTTPX-Correlation-Id. Il permet de retrouver toutes les traces d'une même transaction cross-service. - L'identifiant de l'intent et de la transaction.
- Les identifiants de comptes, masqués pour la confidentialité (format
XXXX***YYYY). - Le montant, l'opération type, la devise.
- La durée d'exécution.
Aucun secret (PIN, OTP, token d'authentification) n'est jamais journalisé, même en mode debug. Les corps de requête sensibles sont filtrés avant hash lors de la canonicalisation d'idempotence.
8.3 Contrôle d'intégrité nocturne
Un job automatique (à livrer en MR5) s'exécute chaque nuit à 02h00 UTC. Il parcourt l'ensemble des comptes actifs et compare, pour chacun :
- La valeur stockée dans
accounts.balance. - La valeur recalculée à partir des écritures :
SUM(credits) - SUM(debits)depuisledgers.
Tout écart est :
- Enregistré dans la table
balance_integrity_checks(rétention indéfinie). - Loggué en niveau
ERRORaveccorrelationId. - Exposé via une métrique Prometheus
ledger_integrity_gap_count. - Envoyé en alerte Slack sur le canal
#compliancevia webhook (URL stockée de manière sécurisée dans Doppler).
Le seuil d'alerte est zéro. Toute anomalie est un incident traité en priorité par le département compliance.
Le job ne modifie jamais aucune donnée. La correction d'un écart, si elle est requise, fait l'objet d'un workflow manuel supervisé, sous la responsabilité du département compliance, et documenté dans un runbook dédié.
8.4 Audit ponctuel à la demande
Pour un besoin de vérification ponctuelle sur un compte spécifique, une commande en ligne est disponible :
pnpm --filter @nex/service-ledger-wallets run audit:account <numéro de compte>Elle produit un rapport JSON détaillant :
- Les informations du compte (titulaire, devise, type, date de création).
- Le solde stocké et le solde recalculé, avec l'écart éventuel.
- La symétrie des écritures (chaque débit a bien un crédit correspondant).
- L'existence éventuelle d'écritures orphelines.
- Un indicateur de cohérence global :
OK,WARNINGouCRITICAL.
Un runbook (infrastructure/docs/runbooks/audit-account.md, à livrer en MR5) décrit l'interprétation des résultats et les actions associées.
9. Gouvernance et rôles
9.1 Permissions et séparation des tâches
Les endpoints publics de l'orchestrator sont protégés par un système de permissions granulaire (RequirePermission), distinguant :
transactions.create: initier une transaction.transactions.read: consulter les transactions.transactions.approve(pour les workflows maker-checker) : approuver une opération initiée par un autre utilisateur.reports.read: consulter les rapports d'historique de compte.compliance.*: actions réservées au département compliance (consulter les intégrité checks, examiner les anomalies).
9.2 Rôles métier
Les rôles applicatifs suivants sont définis, avec leur matrice de permissions associée (détaillée dans le système RBAC du service auth) :
- Utilisateur final : peut initier un P2P, consulter son propre historique.
- Agent : peut initier un cash-in ou cash-out, consulter les transactions de ses clients.
- Master Agent : peut en plus consulter les transactions de tous ses agents affiliés.
- Opérateur support : accès en lecture sur les transactions pour traiter les réclamations.
- Finance / Compliance Officer : accès en lecture étendu, peut consulter les
balance_integrity_checks, initier des audits ponctuels. - Tech Lead / Admin : droits d'administration du système.
9.3 Workflow maker-checker
Les opérations sensibles hors flux courant (paiements en masse, rappels de trésorerie) requièrent la validation de deux utilisateurs distincts :
- Un maker qui initie et soumet l'opération.
- Un checker qui examine, valide ou rejette.
La table bulk_payments porte les champs makerId et checkerId distincts. La contrainte applicative makerId != checkerId est en cours de renforcement (dette compliance identifiée, hors périmètre NEX-180).
10. Limites connues et feuille de route
10.1 Périmètre couvert par NEX-180
Sont couverts et conformes au nouveau modèle atomique :
- Transfert P2P entre utilisateurs (type
TRANSFER). - Cash-in agent et master agent (type
CASH_IN). - Cash-out agent et master agent (type
CASH_OUT).
10.2 Chantiers en cours (tickets de suivi)
Quatre tickets complémentaires sont créés et suivis dans Jira, reliés à NEX-180 :
- NEX-331 — Pattern outbox pour notifications et commissions. Les effets de bord non-critiques (envoi de SMS, calcul de commission différée) sont actuellement en mode fire-and-forget. L'ajout d'un pattern outbox transactionnel garantira leur publication au-moins-une-fois, même en cas de crash du service.
- NEX-332 — Observabilité temps réel des invariants comptables. Métriques Prometheus live, tableaux Grafana, alerting PagerDuty en cas de détection immédiate d'une incohérence — en complément du contrôle nocturne.
- NEX-333 — Migration des flux résiduels vers le modèle intent. Les opérations de recharge mobile, paiements en masse, rappels de trésorerie et distribution de commission n'utilisent pas encore le flow intent. Elles seront migrées pour bénéficier du même niveau de garanties.
- NEX-334 — Cantonnement EME et réconciliation bancaire. Dépend du statut officiel d'EME et des accords bancaires. Hors périmètre technique immédiat.
10.3 Dettes compliance identifiées
Trois dettes ont été documentées dans l'audit NEX-180 et restent à traiter dans des stories dédiées :
- Endpoints d'administration
PUT /v1/accounts/:id/balance/*(ajout, soustraction, gel, dégel de solde) : ces endpoints modifientaccounts.balancesans créer d'écriture dansledgers. Ils constituent la cause racine la plus probable des écarts historiques observés. Recommandation : retrait ou refactorisation pour forcer la création systématique d'une écriture d'ajustement signée. - Absence d'enforcement
makerId != checkerIdsur les paiements en masse. La structure maker-checker existe mais la validation applicative empêchant l'auto-approbation n'est pas en place. - Absence de
correlation_idnatif sur les entités financières — contourné partiellement par un mécanisme de propagation dans NEX-180, à consolider avec un vrai middleware d'identification de requête (NEX-332).
10.4 Gouvernance post-déploiement
La mise en production de NEX-180 est conditionnée à :
- Un pré-check d'intégrité sur l'environnement cible confirmant l'absence de compte en violation des nouvelles contraintes.
- L'approbation de la merge request par au moins deux développeurs backend seniors distincts.
- Une revue adversariale automatisée (
bmad-code-review) attachée à la merge request. - Un benchmark de performance avant/après attestant d'une dégradation p95 inférieure à 20 %.
- Un smoke test sur l'environnement de staging portant sur au moins trente transactions représentatives des trois types couverts.
- Une vérification manuelle des rapports du contrôle nocturne pendant au moins 48 heures post-déploiement.
11. Références
11.1 Documents internes
infrastructure/docs/compliance-audit-2026.md— audit compliance détaillé, 5 piliers A1 à A5, identifiant les conformités et les écarts._bmad-output/implementation-artifacts/NEX-180-ledger-concurrency-hardening.md— spécification complète de la story NEX-180._bmad-output/implementation-artifacts/NEX-180-test-plan.md— plan de tests exhaustif organisé en 11 sections.infrastructure/docs/INTENT_CASHOUT_FLOW.md— description détaillée du flow cash-out avec QR.infrastructure/docs/algo-reporting-historique-compte.md— spécification de l'algorithme de rapport d'historique de compte (NEX-179).CLAUDE.md— conventions de développement du monorepo.
11.2 Code source clé
services/ledger-wallets/src/intents/intents.service.ts— service de gestion des intents, point d'entrée du chemin atomique.services/ledger-wallets/src/transactions/orchestrators/transaction-creation.orchestrator.ts— orchestrateur transactionnel au sens SQL.services/ledger-wallets/src/common/utils/lock-accounts-in-order.ts— helper de verrouillage pessimiste ordonné.services/ledger-wallets/src/common/decorators/retry-on-serialization-failure.decorator.ts— décorateur de rejeu sur erreurs de sérialisation.services/ledger-wallets/src/idempotency/interceptors/idempotency.interceptor.ts— interception d'idempotence transversale.services/ledger-wallets/migrations/000-baseline.sqlà025-create-balance-integrity-checks-table.sql— schéma et évolutions de la base de données.services/orchestrator/src/application/use-cases/intents/confirm-intent.use-case.ts— use-case orchestrateur de la confirmation d'un intent.
11.3 Réglementation et standards
- Réglementation COBAC applicable aux Établissements de Monnaie Électronique.
- Directives BEAC sur le safeguarding des fonds clients.
- Pattern d'idempotence Stripe (référence industrielle) : https://stripe.com/docs/api/idempotent_requests
- Pattern d'outbox transactionnel : https://microservices.io/patterns/data/transactional-outbox.html
- PostgreSQL documentation — Isolation levels : https://www.postgresql.org/docs/current/transaction-iso.html
- PostgreSQL documentation — Explicit locking : https://www.postgresql.org/docs/current/explicit-locking.html
Annexe A — Glossaire
- ACID : propriétés d'une transaction base de données — Atomicité, Cohérence, Isolation, Durabilité.
- Agent : partenaire physique de NxPay qui accueille les clients pour les dépôts et retraits d'espèces.
- Append-only : se dit d'une structure dans laquelle on ne peut qu'ajouter des éléments, jamais modifier ni supprimer.
- Cash-in : dépôt d'argent liquide par un agent sur le compte d'un client.
- Cash-out : retrait d'argent liquide par un client auprès d'un agent.
- Cantonnement (safeguarding) : obligation réglementaire de conserver les fonds des clients sur un compte bancaire ségrégué du patrimoine de l'émetteur.
- COBAC : Commission Bancaire d'Afrique Centrale, régulateur de la zone CEMAC.
- Correlation ID : identifiant unique d'une requête traversant plusieurs services, permettant la traçabilité de bout en bout.
- Deadlock : situation où deux transactions s'attendent mutuellement et ne peuvent avancer.
- Double-entry : comptabilité en partie double, principe selon lequel toute écriture de débit est compensée par une écriture de crédit équivalente.
- Double-spend : défaut qui permet à deux opérations concurrentes de dépenser les mêmes fonds, produisant un écart comptable.
- EME : Établissement de Monnaie Électronique, statut réglementaire d'un émetteur de monnaie électronique.
- Idempotence : propriété d'une opération qui, rejouée plusieurs fois, produit le même résultat que si elle était jouée une seule fois.
- Intent (TransactionIntent) : objet métier représentant l'intention d'exécuter une transaction, porteur de tous ses paramètres et de son cycle de vie.
- Ledger : grand livre comptable, collection d'écritures individuelles de débit et de crédit.
- Lock pessimiste : verrouillage qui retient une ressource pour la durée d'une transaction, empêchant tout accès concurrent.
- Maker-checker : principe de séparation des tâches, selon lequel l'initiateur d'une opération ne peut pas être son propre approbateur.
- Master Agent : agent associé à une organisation partenaire, disposant d'un portefeuille corporate.
- OTP (One-Time Password) : code à usage unique envoyé au client pour valider une opération sensible.
- P2P : Peer-to-Peer, transfert d'argent directement entre deux utilisateurs.
- PostgreSQL : système de gestion de base de données relationnelle utilisé par NxPay.
- Race condition : situation où le résultat d'une opération dépend de l'ordre d'exécution de plusieurs processus concurrents.
- SERIALIZABLE : niveau d'isolation transactionnelle le plus strict, garantissant un résultat équivalent à une exécution séquentielle.
- SERIALIZATION_FAILURE : erreur PostgreSQL levée lorsque deux transactions concurrentes ne peuvent pas être sérialisées sans conflit.
- SoD (Segregation of Duties) : séparation des tâches, principe de gouvernance interne.
- Trésorerie (Treasury) : compte technique de la plateforme, distinct des comptes clients.
- Trigger : procédure automatique exécutée par la base de données en réaction à un événement (insertion, modification, suppression).
- XAF / XOF : codes ISO 4217 des devises Franc CFA d'Afrique centrale (XAF) et Franc CFA d'Afrique de l'Ouest (XOF).
Fin du document.
Version 1.0 — 2026-04-17. Révisions et mises à jour à venir à chaque palier de livraison de NEX-180 et de ses tickets de suivi.