Skip to content

Audit compliance — Service ledger-wallets

Date : 2026-04-17 Auteur : Équipe plateforme NxPay (dans le cadre de NEX-180) Périmètre : Service services/ledger-wallets/ — grand livre, comptes, transactions, intents Statut : Premier audit interne, à valider par Tech Lead backend + Produit/Compliance Origine : Écarts jusqu'à 317 626 XAF détectés par NEX-179 entre accounts.balance et SUM(credits) - SUM(debits) reconstitué depuis ledgers


Résumé exécutif

Le service ledger-wallets possède une architecture double-entry structurellement correcte pour les flux transactionnels normaux (création de transaction via intent ou endpoint legacy), avec verrouillage pessimiste et contraintes d'intégrité au niveau DB.

Cinq faiblesses structurelles expliquent toutefois les écarts comptables observés et représentent des dettes compliance non négligeables :

  1. Endpoints admin PUT /accounts/:id/balance/* modifient les soldes sans créer d'écritures ledger symétriques — violation directe du double-entry. Cause racine probable des écarts observés.
  2. ledgers.created_by jamais assigné en production — piste d'audit incomplète sur le grand livre (alors que transactions.created_by est bien peuplé par certains flux).
  3. Aucun correlation_id / request_id sur les entités transactionnelles — traçabilité inter-services fragile.
  4. Pas d'enforcement applicatif makerId != checkerId sur bulk payments — structure SoD présente mais non gardée dans le code.
  5. Concept de cantonnement (safeguarding) inexistant — pas de modélisation des comptes de cantonnement bancaire, pas de réconciliation automatique.

NEX-180 traite directement les points 3 partiellement et 5 (documentation seulement), via les fondations DB et le durcissement de concurrence. Les points 1, 2 et 4 restent ouverts — voir §Dettes et tickets de suivi.

Verdicts par pilier

PilierVerdictRisqueTraité par NEX-180
A1 — Immutabilité du grand livrePARTIELMAJEUROUI (trigger append-only) + dette ouverte sur endpoints admin
A2 — Piste d'auditPARTIELMOYENPartiel (decorator interceptor posé, enrichissement à suivre)
A3 — Séparation des tâchesPARTIELMOYENNON — à traiter dans une story dédiée
A4 — Double-entry et cohérencePARTIELMOYENOUI (transition atomique + job nocturne d'intégrité)
A5 — CantonnementABSENTMINEUR à court terme, critique à moyen termeNON — dépend du statut EME (NEX-334)

A1 — Immutabilité du grand livre

Exigence : aucune écriture dans ledger_wallet.ledgers ne doit être modifiée ou supprimée après création. Le principe du double-entry veut que toute correction passe par une écriture inverse, jamais par un UPDATE rétroactif.

A1.1 Recherche UPDATE / DELETE sur ledgers

Grep exhaustif (mutations directes sur l'entité Ledger) :

EmplacementOpérationVerdict
services/ledger-wallets/src/ledgers/ledgers.service.ts:17-20repository.create() + repository.save() à la créationOK — création initiale uniquement
services/ledger-wallets/src/transactions/services/ledger-creation.service.ts:57,95,145,241queryRunner.manager.save(Ledger, ...) dans une transactionOK — inserts transactionnels

Aucun UPDATE ledgers ni DELETE FROM ledgers trouvé dans le code de production. L'entité Ledger (src/ledgers/entities/ledger.entity.ts) n'expose aucun setter public ni méthode de mutation.

Verdict A1.1 : CONFORME

A1.2 Endpoints admin modifiant accounts.balance sans ledger

RED FLAG IDENTIFIÉ.

services/ledger-wallets/src/accounts/accounts.controller.ts:99-121 expose quatre endpoints admin :

  • PUT /v1/accounts/:id/balance/add
  • PUT /v1/accounts/:id/balance/subtract
  • PUT /v1/accounts/:id/balance/freeze
  • PUT /v1/accounts/:id/balance/unfreeze

Ces endpoints appellent AccountsService.updateBalance() (src/accounts/accounts.service.ts:143-184) qui fait un queryRunner.manager.save(Account, ...) sans créer d'écriture ledger symétrique.

Impact :

  • Toute utilisation (même légitime) casse l'invariant account.balance == SUM(ledgers) du compte concerné
  • Cause probable des écarts comptables observés par NEX-179
  • Les comptes clients peuvent être débités/crédités sans trace dans le grand livre

Recommandation : ces endpoints doivent être soit supprimés (préférable), soit restreints à des cas de force majeure avec création systématique d'une écriture ledger d'ajustement signée par un opérateur compliance.

Verdict A1.2 : NON CONFORME ❌ — Dette ouverte, à adresser en dehors de NEX-180 (recommandation : ticket dédié de retrait ou de re-sourcing via ledger).

A1.3 Traitement par NEX-180

Un trigger BEFORE UPDATE OR DELETE est ajouté sur ledger_wallet.ledgers (migration 021-ledgers-append-only.sql, backportée en prod via hotfix 9dd5ec0c avant le merge de NEX-180) qui lève une exception PostgreSQL explicite. Cela pose un filet de sécurité DB que le code ne peut pas contourner, même accidentellement.

Cela ne résout pas A1.2 — ce trigger protège la table ledgers mais pas la table accounts, qui reste modifiable via les endpoints admin incriminés.


A2 — Piste d'audit

Exigence : toute écriture financière est tracée avec son auteur (humain ou système), son instant, sa corrélation inter-services, et éventuellement ses métadonnées de sécurité (IP, user-agent).

A2.1 Colonnes d'audit présentes

Entitécreated_bycreated_atupdated_atinitiated_atcompleted_atcorrelation_idip_address / user_agent
Ledger✓ nullable
Transaction✓ nullable✓ (initiated_at)
TransactionIntent
IntentEvent
BulkPaymentAuditLog

A2.2 Peuplement effectif de created_by

Grep des assignations createdBy = :

EmplacementAssigné ?
src/recalls/recalls.service.ts:346,604OUI
src/destruction/destruction.service.ts:253OUI
src/treasury/treasury-distribution.service.ts:142OUI (si operatorId fourni)
src/transactions/services/*.ts (création transaction standard)NON
src/ledgers/* (création Ledger)NON — zéro assignation détectée
src/intents/* (TransactionIntent.initiatorUserId existe mais pas created_by générique)N/A — usage indirect via initiator_user_id

RED FLAG : les écritures Ledger de production n'ont jamais de created_by renseigné. Alors même que la colonne existe, la valeur reste NULL. Aucune traçabilité humaine sur le grand livre pour les transactions ordinaires.

Verdict A2.2 : NON CONFORME ❌ — À traiter dans la Phase 3 de NEX-180 (atomisation transition d'intent) : l'initiatorUserId de l'intent doit être propagé à Transaction.createdBy et à chaque ligne Ledger.createdBy.

A2.3 Audit trail transversal

Tables d'audit dédiées détectées :

  • bulk_payment_audit_logs (src/bulk-payments/entities/bulk-payment-audit-log.entity.ts) — écritures manuelles explicites via BulkPaymentsService
  • intent_events (src/intents/entities/intent-event.entity.ts) — plus riche : actor, ip_address, user_agent, failure_reason, metadata jsonb

Aucun subscriber TypeORM ni intercepteur global ne génère d'audit automatique. Tout est écrit explicitement par le code applicatif.

Verdict A2.3 : PARTIEL — Audit trail couvre bulk payments et intents, pas les autres flux.

A2.4 Corrélation inter-services

Aucune colonne correlation_id, request_id, trace_id n'existe dans les entités de ce service. Un intent NxPay transitant par orchestrator → risk-engine → ledger-wallets ne peut pas être rejoué de bout en bout.

Verdict A2.4 : NON CONFORME — À traiter par NEX-332 (observabilité temps réel et OpenTelemetry).


A3 — Séparation des tâches (Segregation of Duties)

Exigence : un utilisateur ne peut pas être à la fois l'initiateur et l'approbateur d'une même opération sensible (principe maker-checker).

A3.1 Permissions transactions.*

Grep RequirePermission dans services/ledger-wallets/src/ : zéro occurrence. Le service ledger-wallets expose ses endpoints sans guard de permission natif — la segmentation est assumée au niveau orchestrator (qui porte le PermissionGuard).

Verdict A3.1 : CONFORME à l'architecture (ledger-wallets est un service interne, permissions côté orchestrator), sous réserve que tout caller direct soit banni (cf. Phase 6 NEX-180 : retrait de l'endpoint POST /v1/transactions).

A3.2 Workflow maker-checker existant

src/bulk-payments/entities/bulk-payment.entity.ts:77-81 définit un workflow structuré :

  • makerId — initiateur de l'ordre de paiement batch
  • checkerId — approbateur

Migrations référentes :

  • migrations/001-bulk-payment-workflow.sql
  • migrations/003-bulk-payments-maker-checker.sql

Verdict A3.2 : Structure présente.

A3.3 Enforcement makerId != checkerId

Grep des validations makerId === checkerId ou équivalent : aucun résultat. Le code n'empêche pas un même utilisateur d'approuver sa propre soumission.

Verdict A3.3 : NON CONFORME ❌ — Une story dédiée est nécessaire pour ajouter :

  • Validation makerId !== checkerId dans BulkPaymentsService.approve()
  • Domain exception MakerCheckerConflictException mappée à HTTP 403
  • Test unitaire qui prouve le rejet

Cette dette est hors scope NEX-180 et fait l'objet d'une recommandation de ticket séparé (non encore créé — proposition à faire à la clôture de NEX-180).


A4 — Double-entry et cohérence

Exigence : toute modification de solde est jumelée à une écriture symétrique debit/credit dans ledgers, garantissant account.balance == SUM(credits) - SUM(debits) à tout instant.

A4.1 Création transactionnelle normale

src/transactions/services/ledger-creation.service.ts:102-149 — méthode createDoubleEntry() :

  • Crée une écriture DEBIT côté source
  • Crée une écriture CREDIT côté destination
  • Lie les deux via counterparty_ledger_id

src/transactions/services/ledger-creation.service.ts:156-249createFeeLedgerEntry() :

  • DEBIT côté client (frais retenus)
  • CREDIT côté SYS-FEE-COLLECTION

Verdict A4.1 : CONFORME

A4.2 updateBalance() admin

Couvert en A1.2 — mutation de solde sans ledger associé. Source racine des écarts comptables.

Verdict A4.2 : NON CONFORME

A4.3 Cohérence balance_after

Le champ balance_after du ledger capture un snapshot post-opération. Pattern observé : assigné avant save() sur la base de la balance courante de l'entité. Sans verrouillage pessimiste systématique au moment de la lecture, deux transactions parallèles peuvent produire des snapshots incohérents.

Le code utilise lock: { mode: 'pessimistic_write' } dans accounts.service.ts:155,194, mais uniquement dans les chemins récents. Les chemins legacy (Process*UseCase côté orchestrator + endpoint direct POST /v1/transactions) ne garantissent pas cette étanchéité.

Verdict A4.3 : PARTIEL ⚠️ — Traité par NEX-180 Phase 3 (transition atomique avec SERIALIZABLE + SELECT FOR UPDATE).

A4.4 Contraintes DB préexistantes

migrations/000-baseline.sql:95-98 :

sql
CONSTRAINT "CHK_accounts_balance_consistency" CHECK (balance = (available_balance + frozen_balance))
CONSTRAINT "CHK_accounts_positive_available_balance" CHECK (available_balance >= 0)
CONSTRAINT "CHK_accounts_positive_balance" CHECK (balance >= 0)
CONSTRAINT "CHK_accounts_positive_frozen_balance" CHECK (frozen_balance >= 0)

La contrainte CHK_accounts_positive_balance empêche tout compte d'aller en négatif — y compris les comptes TREASURY qui peuvent légitimement avoir un solde négatif temporaire (prélèvement avant réapprovisionnement).

Action NEX-180 : migration 023-harden-account-balance-constraints.sql qui remplace CHK_accounts_positive_balance par une version tolérant les comptes treasury (valeur lowercase observée dans system-accounts.constants.ts). Les autres contraintes sont laissées intactes — elles sont saines.

A4.5 Écritures orphelines

ledgers.transaction_id porte une contrainte FOREIGN KEY ... ON DELETE CASCADE (migrations/000-baseline.sql:641). Aucune ligne ledgers ne peut exister sans transactions (ou avec transaction_id NULL pour les ajustements non-transactionnels).

Inverse non vérifié : transactions sans écriture ledgers. Un audit SQL ponctuel en staging est recommandé :

sql
SELECT COUNT(*)
FROM ledger_wallet.transactions t
WHERE NOT EXISTS (SELECT 1 FROM ledger_wallet.ledgers l WHERE l.transaction_id = t.id)
  AND t.status = 'completed';

À exécuter manuellement avant application des migrations NEX-180. Si résultat > 0 → reporting des anomalies à l'équipe finance avant application.

Verdict A4.5 : À VÉRIFIER EN PRÉ-DÉPLOIEMENT.


A5 — Cantonnement (Safeguarding)

Exigence EME en zone CEMAC : les fonds clients doivent être cantonnés sur un compte bancaire segregué du patrimoine de l'émetteur. Invariant attendu :

SUM(tous les comptes clients actifs en XAF)
==
SOLDE du compte de cantonnement bancaire en XAF

A5.1 Concept system_account

src/common/constants/system-accounts.constants.ts:1-119 définit des comptes techniques :

  • SUSPENSE
  • TREASURY
  • FEE_COLLECTION
  • BLACK_ACCOUNT

Ces comptes sont isolés par entity_type = 'system' et excluded des listes publiques.

Verdict A5.1 : Architecture technique présente pour distinguer comptes clients vs comptes techniques.

A5.2 Compte de cantonnement bancaire

Aucun concept SAFEGUARDING ou CANTONNEMENT n'existe dans les entités, le code ou les migrations. Aucune table ne modélise de réconciliation bancaire.

Verdict A5.2 : NON MODÉLISÉ — Dette compliance. Voir NEX-334.

A5.3 Prérequis avant implémentation

  • Clarification du statut EME NxPay (en cours / obtenu / délégué à une banque partenaire)
  • Identification de la banque de cantonnement et accès aux extracts

Tant que ces prérequis ne sont pas tranchés, aucune implémentation code n'est pertinente.


Dettes et tickets de suivi

Tickets créés et liés à NEX-180 (via Relates to)

TicketSujetStatut
NEX-331Outbox pattern pour notifications et commissionsBacklog
NEX-332Observabilité temps réel des invariants comptablesBacklog
NEX-333Migration recharge/bulk/treasury/commission vers intentsBacklog
NEX-334Cantonnement EME + réconciliation bancaireBacklog

Dettes identifiées par cet audit, sans ticket encore créé

Trois sujets émergent du présent audit et ne sont couverts ni par NEX-180, ni par les 4 tickets ci-dessus. Ils doivent faire l'objet de tickets dédiés :

  1. Retrait ou sécurisation des endpoints PUT /accounts/:id/balance/* (A1.2, A4.2) — violation double-entry, cause racine probable des écarts. Recommandation : retrait. Si besoin absolu conservé, alors création systématique d'un ledger d'ajustement avec created_by = operatorId + validation maker-checker.
  2. Enforcement makerId != checkerId sur les workflows maker-checker (A3.3) — validation applicative + test dédié. Impact faible, gain compliance élevé.
  3. Propagation created_by sur les écritures Ledger (A2.2) — intégré partiellement dans NEX-180 Phase 3 mais mérite un audit post-merge pour s'assurer que toutes les écritures sont couvertes.

À la clôture de NEX-180, ces 3 items seront proposés à l'équipe produit pour priorisation en sprint suivant.


Validation

Validation technique : Tech Lead backend Validation produit / compliance : Responsable Produit (et Compliance si disponible)

ValidateurRôleDateCommentaire
(à compléter)Tech Lead backend(à compléter)
(à compléter)Produit / Compliance(à compléter)

Document soumis en review dans le cadre de la MR1 de NEX-180.


Annexe — Méthodologie

  • Analyse statique du code de services/ledger-wallets/ (TypeScript + migrations SQL) au commit b6a8f28b
  • Grep exhaustifs sur les patterns de mutation (UPDATE, .save(Ledger, balance =, createdBy =)
  • Vérification des contraintes DB existantes dans migrations/000-baseline.sql
  • Croisement avec les spécifications Jira NEX-179 (détection des écarts) et NEX-180 (hardening ciblé)
  • Aucune exécution de requête sur base staging/prod (respect de la règle interne "DB read-only", pas d'action intrusive)

Derniere mise a jour:

NxPay — Plateforme fintech CEMAC