Algorithme — Reporting Historique de Compte (Export Excel)
Documentation complète de l'algorithme de génération du rapport d'historique de compte. Destinée à servir de spécification pour l'implémentation backend (NestJS) ou backoffice (Nuxt).
1. Vue d'ensemble
Le reporting produit un fichier Excel multi-onglets qui retrace l'évolution complète d'un compte depuis sa création : chaque mouvement (crédit/débit), les acteurs impliqués, les soldes intermédiaires, et une synthèse agrégée par jour.
Onglets générés
| # | Onglet | Contenu |
|---|---|---|
| 1 | Résumé Compte | Fiche d'identité + statistiques agrégées |
| 2 | Historique Complet | Chronologie détaillée de chaque opération |
| 3 | Synthèse Journalière | Agrégation par jour (crédits, débits, net, cumulé) |
| 4 | Acteurs (Expéditeurs) | Liste des personnes ayant crédité le compte |
2. Paramètres d'entrée
| Paramètre | Type | Obligatoire | Description |
|---|---|---|---|
accountNumber | string | oui | Numéro de compte NxPay (ex: NX6071979016) |
dateFrom | Date | non | Date de début de la période (défaut: date de création du compte) |
dateTo | Date | non | Date de fin de la période (défaut: now) |
3. Extraction des données
3.1 Étape 1 — Récupérer le compte cible
SELECT
a.id,
a.wallet_id,
a.account_number,
a.currency_code,
a.balance,
a.available_balance,
a.frozen_balance,
a.status,
a.account_type,
a.name,
a.created_at
FROM ledger_wallet.accounts a
WHERE a.account_number = :accountNumber;Données extraites (utilisées dans le résumé) :
| Champ | Usage |
|---|---|
id | Clé pour les jointures suivantes |
account_number | Affiché dans le résumé |
currency_code | Devise du compte (XAF, XOF) — utilisée dans tous les libellés monétaires |
balance | Solde actuel affiché |
available_balance | Solde disponible affiché |
frozen_balance | Solde gelé affiché |
status | Statut du compte (active, frozen, closed) |
account_type | Type (current, savings, commission) |
name | Nom du compte |
created_at | Date de création |
Erreur si : aucun compte trouvé → retourner une erreur 404
ACCOUNT_NOT_FOUND.
3.2 Étape 2 — Identifier le titulaire
Le nom du titulaire n'est pas stocké dans la table accounts. Il est résolu via le champ recipient_name (pour les crédits entrants) ou sender_name (pour les débits sortants) de la première transaction trouvée.
SELECT
COALESCE(
(SELECT t.recipient_name FROM ledger_wallet.transactions t
WHERE t.destination_account_id = :accountId AND t.recipient_name IS NOT NULL
LIMIT 1),
(SELECT t.sender_name FROM ledger_wallet.transactions t
WHERE t.source_account_id = :accountId AND t.sender_name IS NOT NULL
LIMIT 1)
) AS holder_name;Alternative : appeler le service customer-profiles-kyc via l'orchestrateur pour récupérer le nom depuis le person_id du wallet. Plus fiable mais nécessite un appel inter-service.
3.3 Étape 3 — Récupérer toutes les transactions avec jointures
C'est la requête principale. Elle joint transactions, accounts (source + destination), et ledgers pour obtenir toutes les données en un seul passage.
SELECT
-- Transaction
t.id AS transaction_id,
t.operation_type,
t.amount,
t.fee_amount,
t.status,
t.description,
t.reference_id,
t.external_reference,
t.sender_name,
t.recipient_name,
t.source_account_id,
t.destination_account_id,
t.initiated_at,
t.completed_at,
t.metadata,
-- Comptes joints (numéros lisibles)
sa.account_number AS source_account_number,
da.account_number AS dest_account_number,
-- Ledger (écriture comptable côté compte cible)
l.id AS ledger_id,
l.entry_type,
l.balance_after,
l.available_balance_after,
l.frozen_balance_after,
l.operation_type AS ledger_operation_type,
l.created_at AS ledger_created_at
FROM ledger_wallet.transactions t
-- Jointure compte source (peut être NULL pour certains types)
LEFT JOIN ledger_wallet.accounts sa
ON sa.id = t.source_account_id
-- Jointure compte destination (peut être NULL pour certains types)
LEFT JOIN ledger_wallet.accounts da
ON da.id = t.destination_account_id
-- Jointure ledger : écriture comptable du point de vue du compte cible UNIQUEMENT
LEFT JOIN ledger_wallet.ledgers l
ON l.transaction_id = t.id
AND l.account_id = :accountId
WHERE
(t.source_account_id = :accountId OR t.destination_account_id = :accountId)
AND t.initiated_at >= :dateFrom
AND t.initiated_at <= :dateTo
ORDER BY t.initiated_at ASC;Points critiques :
Le LEFT JOIN sur
ledgersfiltre surl.account_id = :accountIdpour n'obtenir que l'écriture du point de vue du compte cible. Une transaction génère 2 écritures ledger (une par côté), on ne veut que celle du compte analysé.Les
treasury_recalln'ont pas d'écriture ledger associée dans certains cas (prélèvement direct). Le LEFT JOIN gère ce cas : les champs ledger seront NULL.L'
ORDER BY t.initiated_at ASCest essentiel pour reconstruire la chronologie du solde.Le compte destination
00000000-0000-0000-0000-000000000202est le compte système trésorerie XAF (SYS-TREASURY-XAF). Il apparaît comme destination des recalls.
4. Classification des opérations
Chaque opération est classifiée selon son sens par rapport au compte analysé.
4.1 Logique de classification
POUR chaque transaction T :
SI T.destination_account_id == accountId ET T.operation_type == 'withdrawal' :
→ CRÉDIT (entrant) — argent reçu sur le compte
→ Couleur : vert (#E2EFDA)
SINON SI T.source_account_id == accountId ET T.operation_type == 'treasury_recall' :
→ DÉBIT (recall) — argent prélevé du compte
→ Couleur : rouge (#FCE4EC)
SINON :
→ Classification selon operation_type (voir table ci-dessous)4.2 Table complète des types d'opération
operation_type (DB) | Libellé FR | Sens pour le compte | Description métier |
|---|---|---|---|
withdrawal | Cash-Out (retrait client) | CRÉDIT si destination | Un client retire du cash : son wallet débite, le compte agent crédite |
treasury_recall | Recall Trésorerie | DÉBIT si source | La trésorerie prélève les fonds accumulés sur le compte agent |
transfer | Transfert | CRÉDIT si destination, DÉBIT si source | Transfert P2P entre utilisateurs |
deposit | Dépôt | CRÉDIT si destination | Dépôt d'argent (cash-in) |
payment | Paiement | DÉBIT si source | Paiement marchand |
commission | Commission | CRÉDIT si destination | Commission versée à un agent |
fee | Frais | DÉBIT si source | Prélèvement de frais |
refund | Remboursement | CRÉDIT si destination | Remboursement d'une opération |
bulk_payment | Paiement en masse | DÉBIT si source | Paiement bulk (salaires, etc.) |
4.3 Parsing des metadata JSON
Le champ metadata (JSONB) contient des informations contextuelles variables selon le type d'opération.
Pour withdrawal (AGENT_CASH_OUT) :
{
"agentPersonId": "e43d7064-...",
"transactionTypeCode": "AGENT_CASH_OUT"
}| Champ metadata | Usage |
|---|---|
transactionTypeCode | Code métier de la transaction (ex: AGENT_CASH_OUT) |
agentPersonId | UUID de l'agent ayant effectué l'opération |
Pour treasury_recall :
{
"reason": "correction_comptable",
"isDirect": true,
"executedBy": "system",
"holderName": "Makaya MAKAYA",
"holderType": "agent",
"accountNumber": "NX6071979016"
}ou (recall via demande) :
{
"reason": "recall",
"initiatorType": "account_holder",
"recallRequestId": "db7686f2-..."
}| Champ metadata | Usage |
|---|---|
reason | Raison du recall (recall, correction_comptable, fraude, etc.) |
isDirect | true si prélèvement direct (sans workflow d'approbation) |
executedBy | Qui a exécuté (system, UUID admin) |
holderName | Nom du titulaire (snapshot) |
holderType | Type d'entité (agent, player, merchant) |
initiatorType | Qui a initié (account_holder, backoffice, direct) |
recallRequestId | UUID de la demande de recall (table recall_requests) |
Algorithme de parsing :
POUR chaque transaction T :
SI T.metadata n'est pas NULL :
parser le JSON
transactionTypeCode = metadata.transactionTypeCode OU metadata.reason
agentPersonId = metadata.agentPersonId OU metadata.executedBy
SI metadata.holderName existe :
extraInfo += "Titulaire: " + metadata.holderName
SI metadata.isDirect existe :
extraInfo += " | Direct: " + metadata.isDirect5. Calculs et agrégations
5.1 Statistiques globales (Onglet Résumé)
creditAmounts = [] -- liste des montants de type CRÉDIT
debitAmounts = [] -- liste des montants de type DÉBIT
dates = {} -- ensemble des dates distinctes d'activité
POUR chaque transaction T :
SI T.operation_type == 'withdrawal' ET destination == accountId :
creditAmounts.push(T.amount)
SINON SI T.operation_type == 'treasury_recall' ET source == accountId :
debitAmounts.push(T.amount)
dates.add(T.initiated_at.date)
-- Calculs
totalCredits = SUM(creditAmounts)
totalDebits = SUM(debitAmounts)
nbCredits = COUNT(creditAmounts)
nbDebits = COUNT(debitAmounts)
moyenneCredit = totalCredits / nbCredits (si nbCredits > 0, sinon 0)
minCredit = MIN(creditAmounts) (si nbCredits > 0, sinon 0)
maxCredit = MAX(creditAmounts) (si nbCredits > 0, sinon 0)
soldeNet = totalCredits - totalDebits
premiereDate = MIN(dates)
derniereDate = MAX(dates)
nbOperations = COUNT(toutes les transactions)
joursActifs = COUNT(dates distinctes)Vérification d'intégrité : soldeNet DOIT être égal à account.balance. Si ce n'est pas le cas, ajouter un avertissement dans le rapport.
5.2 Historique chronologique (Onglet Historique Complet)
Chaque ligne du tableau représente une opération, dans l'ordre chronologique strict (ORDER BY initiated_at ASC).
Colonnes produites :
| # | Colonne | Source | Transformation |
|---|---|---|---|
| 1 | # | compteur séquentiel | auto-incrémenté à partir de 1 |
| 2 | Date & Heure | t.initiated_at | Format YYYY-MM-DD HH:mm:ss (tronquer les microsecondes et le timezone) |
| 3 | Type Opération | t.operation_type | Mapper vers le libellé FR (voir table §4.2) |
| 4 | Sens | logique §4.1 | CRÉDIT (entrant) ou DÉBIT (recall) |
| 5 | Expéditeur | t.sender_name | Si NULL → — |
| 6 | Destinataire | t.recipient_name | Si NULL → — |
| 7 | Compte Source | sa.account_number | Si NULL → — |
| 8 | Compte Destination | da.account_number | Si NULL → — |
| 9 | Montant | t.amount | Entier, formaté #,##0 |
| 10 | Frais | t.fee_amount | Entier, formaté #,##0 |
| 11 | Solde Après | l.balance_after | Depuis le ledger. Si NULL (pas d'écriture ledger) → — |
| 12 | Solde Disponible Après | l.available_balance_after | Depuis le ledger. Si NULL → — |
| 13 | Statut | t.status | Valeur brute (completed, pending, failed) |
| 14 | Référence NxPay | t.reference_id | Ex: NXPTX2604010001WZ21. Si NULL → — |
| 15 | Réf. Externe | t.external_reference | Référence fournisseur externe. Si NULL → — |
| 16 | Type Transaction (metadata) | t.metadata.transactionTypeCode ou t.metadata.reason | Parsing JSON. Si absent → — |
| 17 | Agent Person ID | t.metadata.agentPersonId ou t.metadata.executedBy | Parsing JSON. Si absent → — |
| 18 | Description | t.description ou info extraite des metadata | Priorité: description > extraInfo construite depuis metadata > — |
| 19 | Transaction ID | t.id | UUID brut pour traçabilité |
Lignes de totaux (ajoutées après la dernière opération, séparées par une ligne vide) :
| Ligne | Colonne "Sens" | Colonne "Montant" |
|---|---|---|
| Total Crédits | "Total Crédits" (vert) | SUM de tous les crédits |
| Total Recalls | "Total Recalls" (rouge) | SUM de tous les débits |
| SOLDE NET | "SOLDE NET" (gras 13pt) | totalCredits - totalDebits |
Style conditionnel des lignes :
SI sens == "CRÉDIT (entrant)" :
→ background de toute la ligne : vert clair (#E2EFDA)
→ colonne Sens : police verte (#006600), gras
SI sens == "DÉBIT (recall)" :
→ background de toute la ligne : rouge clair (#FCE4EC)
→ colonne Sens : police rouge (#CC0000), gras5.3 Synthèse journalière (Onglet Synthèse Journalière)
Agrégation de toutes les opérations par date calendaire.
daily = OrderedDict() -- clé: date (YYYY-MM-DD), préserve l'ordre chronologique
POUR chaque transaction T :
date = T.initiated_at.date -- tronquer au jour
SI date n'est PAS dans daily :
daily[date] = { credits: 0, credit_count: 0, debits: 0, debit_count: 0 }
SI T est un CRÉDIT :
daily[date].credits += T.amount
daily[date].credit_count += 1
SINON SI T est un DÉBIT :
daily[date].debits += T.amount
daily[date].debit_count += 1Colonnes produites :
| # | Colonne | Calcul |
|---|---|---|
| 1 | Date | Date du jour (YYYY-MM-DD) |
| 2 | Nb Crédits | credit_count du jour |
| 3 | Total Crédits (XAF) | credits du jour |
| 4 | Nb Recalls | debit_count du jour |
| 5 | Total Recalls (XAF) | debits du jour |
| 6 | Net du Jour (XAF) | credits - debits du jour |
| 7 | Solde Cumulé (XAF) | Somme cumulative des nets depuis le jour 1 |
Calcul du cumulé :
cumul = 0
POUR chaque jour DANS daily (ordre chronologique) :
net = credits - debits
cumul = cumul + net
→ Écrire cumul dans la colonne "Solde Cumulé"Style :
Net du Jour: vert gras si >= 0, rouge gras si < 0Solde Cumulé: toujours gras
5.4 Acteurs / Expéditeurs (Onglet Acteurs)
Filtre uniquement les opérations de type CRÉDIT (argent reçu).
POUR chaque transaction T :
SI T.operation_type != 'withdrawal' :
SKIP
→ Ajouter une ligne avec les infos de l'expéditeurColonnes produites :
| # | Colonne | Source |
|---|---|---|
| 1 | # | Compteur séquentiel |
| 2 | Nom Expéditeur | t.sender_name |
| 3 | Compte Source | sa.account_number |
| 4 | Montant Envoyé (XAF) | t.amount |
| 5 | Date | t.initiated_at tronqué au jour (YYYY-MM-DD) |
| 6 | Heure | t.initiated_at partie heure (HH:mm:ss) |
| 7 | Référence | t.reference_id |
| 8 | Type Transaction | t.metadata.transactionTypeCode |
Ligne de total (après la dernière ligne, séparée par une ligne vide) :
- Colonne 1 : "TOTAL"
- Colonne 2 : "{N} expéditeurs"
- Colonne 4 : SUM de tous les montants
6. Formatage Excel
6.1 Palette de couleurs
| Nom | Code Hex | Usage |
|---|---|---|
| Header background | #1F4E79 | En-têtes de colonnes (bleu foncé) |
| Header font | #FFFFFF | Texte des en-têtes (blanc) |
| Crédit (entrant) | #E2EFDA | Background des lignes crédit (vert clair) |
| Débit (recall) | #FCE4EC | Background des lignes débit (rouge clair) |
| Résumé / Totaux | #D6E4F0 | Background des lignes de résumé (bleu clair) |
| Texte crédit | #006600 | Police verte pour le sens CRÉDIT |
| Texte débit | #CC0000 | Police rouge pour le sens DÉBIT |
| Bordure | #B0B0B0 | Bordure fine gris clair sur toutes les cellules |
6.2 Format des montants
Tous les montants financiers sont affichés en entier (pas de décimales) avec séparateur de milliers.
Format Excel : #,##0
Exemples : 120 300 | 1 979 700 | 39 000Conversion :
TRUNC(amount)— on tronque les 8 décimales de la base (DECIMAL(20,8)) car les devises XAF/XOF n'ont pas de sous-unité.
6.3 Format des dates
| Contexte | Format | Exemple |
|---|---|---|
| Date & Heure (historique) | YYYY-MM-DD HH:mm:ss | 2026-04-01 14:20:03 |
| Date seule (synthèse) | YYYY-MM-DD | 2026-04-01 |
| Heure seule (acteurs) | HH:mm:ss | 14:20:03 |
Transformation depuis le timestamp PostgreSQL : tronquer les microsecondes et le suffixe timezone
+00.
6.4 Règles de mise en page
- Freeze panes : figer la première ligne (en-têtes) sur tous les onglets sauf Résumé
- Auto-filter : activer le filtre automatique sur l'onglet Historique Complet (toutes les colonnes)
- Auto-width : ajuster la largeur des colonnes au contenu, avec min=10 et max=40-45 caractères
- Wrap text : activé sur les en-têtes
- Bordure : fine (
thin) gris clair sur toutes les cellules de données - Valeurs NULL : afficher
—(tiret cadratin) quand une donnée est absente
7. Comptes système connus
| UUID | Account Number | Rôle |
|---|---|---|
00000000-0000-0000-0000-000000000202 | SYS-TREASURY-XAF | Compte trésorerie système XAF |
Ces comptes apparaissent comme contrepartie dans les recalls et les opérations système. Le account_number affiché sera celui résolu par la jointure (ex: SYS-TREASURY-XAF).
8. Vérification d'intégrité
À la fin de la génération, vérifier la cohérence :
soldeCalculé = SUM(crédits) - SUM(débits)
soldeRéel = account.balance
SI soldeCalculé != soldeRéel :
→ Ajouter un avertissement "INCOHÉRENCE DE SOLDE" dans l'onglet Résumé
→ Logger une alerte (investigation nécessaire)Si la période est filtrée (dateFrom/dateTo non par défaut), cette vérification ne s'applique pas car le solde partiel ne correspond pas au solde courant.
9. Diagramme de flux
┌─────────────────┐
│ accountNumber │
│ dateFrom/dateTo │
└────────┬────────┘
│
▼
┌─────────────────────────────┐
│ 1. SELECT account │
│ FROM accounts │
│ WHERE account_number = ? │
└────────┬────────────────────┘
│ account.id
▼
┌─────────────────────────────┐
│ 2. SELECT holder_name │
│ FROM transactions │
│ (premier match) │
└────────┬────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ 3. SELECT transactions + accounts + ledgers │
│ WHERE source OR destination = account.id │
│ ORDER BY initiated_at ASC │
└────────┬─────────────────────────────────────┘
│ Liste de N opérations
▼
┌───────────────────────────────────┐
│ 4. POUR chaque opération : │
│ - Classifier (CRÉDIT / DÉBIT) │
│ - Parser metadata JSON │
│ - Extraire acteur, type, raison│
│ - Accumuler dans les agrégats │
└────────┬──────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 5. Générer 4 onglets Excel : │
│ a) Résumé (stats calculées) │
│ b) Historique (liste chronologique)│
│ c) Synthèse journalière (agrégat) │
│ d) Acteurs (filtre crédits seuls) │
└────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────┐
│ 6. Vérification d'intégrité │
│ soldeCalculé == account.balance│
└────────┬─────────────────────────┘
│
▼
┌──────────────────┐
│ Fichier .xlsx │
└──────────────────┘10. Considérations d'implémentation
Performance
- Index utilisés : la requête principale s'appuie sur
idx_transactions_source_account,idx_transactions_dest_account, etidx_transactions_initiated_at. Pas besoin d'index supplémentaire. - Volume : pour un compte agent actif, prévoir ~50-200 transactions/jour. La requête principale reste performante jusqu'à ~100k lignes.
- Streaming : pour les gros volumes, générer le fichier Excel en streaming plutôt qu'en mémoire complète.
Sécurité
- Accès : restreindre la génération aux rôles backoffice (
admin,finance,compliance). - Audit : logger chaque génération de rapport (qui, quand, quel compte).
- Données sensibles : le rapport contient des noms complets et des numéros de compte. Marquer le fichier comme confidentiel.
Format de sortie
- Nom du fichier :
{accountNumber}_historique_{dateFrom}_{dateTo}.xlsx - Exemple :
NX6071979016_historique_2026-03-18_2026-04-07.xlsx - MIME type :
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet