Skip to content
StableAudienceDevSécuritéAudit banqueQAOwner@architecture-teamDernière revue2026-05-22

Transactions — Fonctionnement métier et architecture technique

Cycle de vie d'une transaction (intents et state machine), événements d'intent, principes de conception du système (double-entrée, immutabilité, idempotence, intégrité concurrente), services et modèle de données, séquence détaillée d'une transaction. Pour la vue d'ensemble, voir Système de transactions — overview.

3. Fonctionnement métier des transactions

3.1 Types de transactions

Nex opère trois types de transactions entre utilisateurs finaux :

TypeDescriptionActeursDevise typique
P2P (Transfer)Transfert d'argent d'un utilisateur vers un autre utilisateurÉmetteur + RécepteurXAF/XOF
Cash-inL'agent dépose de l'argent liquide sur le compte d'un clientAgent + ClientXAF/XOF
Cash-outL'agent remet de l'argent liquide au client (retrait du compte)Agent + ClientXAF/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 Nex, 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) : Nex 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 Nex 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 :

É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 DEBIT de 1 000 sur A et un CREDIT de 1 000 sur B. Les deux écritures se référencent l'une l'autre via leur counterparty_ledger_id.
  • Si la transaction comporte des frais de 50 XAF, deux écritures supplémentaires sont produites : un DEBIT de 50 sur A et un CREDIT de 50 sur le compte technique FEE_COLLECTION.
  • Si la transaction génère une commission agent, deux écritures supplémentaires encore : un DEBIT sur le compte technique TREASURY et un CREDIT sur 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 (format NXPTXYYMMDDXXXXXXX), 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 idempotencyKey du 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 HTTP X-Idempotency-Key) : empêche l'exécution de deux paiements pour la même intention.

Le mécanisme est fondé sur le pattern Stripe :

  1. Le client génère un UUID v4 unique pour une opération donnée.
  2. 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).
  3. 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.
  4. Si la clé existe avec un hash différent → le serveur rejette la requête avec une erreur de conflit (HTTP 422).
  5. 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) :

  1. Verrou pessimiste exclusif au niveau ligne de base de données.
  2. Ordre canonique des verrouillages pour éviter les interblocages (deadlocks).
  3. Isolation transactionnelle serializable.
  4. 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.

TableRôleVolumétrie attendue
walletsUn par entité active (utilisateur, agent, organisation, système)1 / utilisateur
accountsMulti-devises par wallet, porte le solde (balance, available_balance, frozen_balance)1 à 3 / wallet
transactionsChaque opération financière enregistréecroissante
ledgersÉcritures comptables individuelles (debit et credit), append-only2 à 6 par transaction
transaction_intentsCycle de vie des intents1 / transaction
intent_eventsJournal des transitions d'état, append-only2 à 8 / intent
idempotency_keysStockage des clés d'idempotence, TTL 48htransitoire
balance_integrity_checksHistorique des contrôles d'intégrité nocturnesré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 >= 0 et frozen_balance >= 0 : sous-soldes toujours positifs.

Contraintes clés sur ledgers :

  • Foreign key transaction_id vers transactions (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 :

Phase 3 — détail de la transaction SQL atomique côté ledger-wallets

Avant le POST /transition SUCCEEDED, l'Orchestrator réalise : résolution des comptes + validations métier + risk engine. À réception, ledger-wallets exécute en une seule transaction SQL SERIALIZABLE :

  1. Lock intent
  2. Lock accounts (ordre ASC pour éviter les deadlocks)
  3. Check balance
  4. Create transaction
  5. Create ledgers (double-entry)
  6. Update balances
  7. Update intent
  8. Log event
  9. COMMIT (atomic)

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.


Nex — Plateforme fintech CEMAC