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 :
- 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. ledgers.created_byjamais assigné en production — piste d'audit incomplète sur le grand livre (alors quetransactions.created_byest bien peuplé par certains flux).- Aucun
correlation_id/request_idsur les entités transactionnelles — traçabilité inter-services fragile. - Pas d'enforcement applicatif
makerId != checkerIdsur bulk payments — structure SoD présente mais non gardée dans le code. - 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
| Pilier | Verdict | Risque | Traité par NEX-180 |
|---|---|---|---|
| A1 — Immutabilité du grand livre | PARTIEL | MAJEUR | OUI (trigger append-only) + dette ouverte sur endpoints admin |
| A2 — Piste d'audit | PARTIEL | MOYEN | Partiel (decorator interceptor posé, enrichissement à suivre) |
| A3 — Séparation des tâches | PARTIEL | MOYEN | NON — à traiter dans une story dédiée |
| A4 — Double-entry et cohérence | PARTIEL | MOYEN | OUI (transition atomique + job nocturne d'intégrité) |
| A5 — Cantonnement | ABSENT | MINEUR à court terme, critique à moyen terme | NON — 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) :
| Emplacement | Opération | Verdict |
|---|---|---|
services/ledger-wallets/src/ledgers/ledgers.service.ts:17-20 | repository.create() + repository.save() à la création | OK — création initiale uniquement |
services/ledger-wallets/src/transactions/services/ledger-creation.service.ts:57,95,145,241 | queryRunner.manager.save(Ledger, ...) dans une transaction | OK — 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/addPUT /v1/accounts/:id/balance/subtractPUT /v1/accounts/:id/balance/freezePUT /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_by | created_at | updated_at | initiated_at | completed_at | correlation_id | ip_address / user_agent |
|---|---|---|---|---|---|---|---|
Ledger | ✓ nullable | ✓ | — | — | — | ✗ | ✗ |
Transaction | ✓ nullable | ✓ (initiated_at) | — | ✓ | ✓ | ✗ | ✗ |
TransactionIntent | ✗ | ✓ | ✓ | — | — | ✗ | ✗ |
IntentEvent | — | ✓ | — | — | — | ✗ | ✓ |
BulkPaymentAuditLog | ✓ | ✓ | — | — | — | ✗ | ✗ |
A2.2 Peuplement effectif de created_by
Grep des assignations createdBy = :
| Emplacement | Assigné ? |
|---|---|
src/recalls/recalls.service.ts:346,604 | OUI |
src/destruction/destruction.service.ts:253 | OUI |
src/treasury/treasury-distribution.service.ts:142 | OUI (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 viaBulkPaymentsServiceintent_events(src/intents/entities/intent-event.entity.ts) — plus riche :actor,ip_address,user_agent,failure_reason,metadatajsonb
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 batchcheckerId— approbateur
Migrations référentes :
migrations/001-bulk-payment-workflow.sqlmigrations/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 !== checkerIddansBulkPaymentsService.approve() - Domain exception
MakerCheckerConflictExceptionmappé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-249 — createFeeLedgerEntry() :
- 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 :
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é :
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 XAFA5.1 Concept system_account
src/common/constants/system-accounts.constants.ts:1-119 définit des comptes techniques :
SUSPENSETREASURYFEE_COLLECTIONBLACK_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)
| Ticket | Sujet | Statut |
|---|---|---|
| NEX-331 | Outbox pattern pour notifications et commissions | Backlog |
| NEX-332 | Observabilité temps réel des invariants comptables | Backlog |
| NEX-333 | Migration recharge/bulk/treasury/commission vers intents | Backlog |
| NEX-334 | Cantonnement EME + réconciliation bancaire | Backlog |
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 :
- 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 aveccreated_by = operatorId+ validation maker-checker. - Enforcement
makerId != checkerIdsur les workflows maker-checker (A3.3) — validation applicative + test dédié. Impact faible, gain compliance élevé. - Propagation
created_bysur les écrituresLedger(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)
| Validateur | Rôle | Date | Commentaire |
|---|---|---|---|
| (à 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 commitb6a8f28b - 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)