Aller au contenu

MANUEL TECHNIQUE : Ajouter une Nouvelle Banque

Version: 1.0 Date: 2025-11-12 Difficulté: ⭐⭐ Intermédiaire Durée: 30-45 minutes


Vue d'Ensemble

Ce guide vous permet d'ajouter une nouvelle banque au système de traitement des emails bancaires en 4 étapes simples.

L'architecture extensible permet d'ajouter des parsers personnalisés sans modifier le code existant.


Pré-requis

  • Accès au code source
  • Échantillons d'emails de la nouvelle banque (minimum 3)
  • Connaissance Python (base)
  • Accès base de données

Architecture

flowchart TD
  EP["EmailProcessor<br/>(orchestrateur, inchangé)"] --> GP["_get_parser<br/>(sélection automatique du parser)"]
  GP --> Q{"bank.parser_class<br/>existe ?"}
  Q -->|OUI| SPE["Parser spécifique<br/>(AFGBankParser, SGBankParser, XYZBankParser…)"]
  Q -->|NON| GEN["Parser générique<br/>(HTMLParser ou PDFParser)"]
  SPE --> EX["Extraction des données<br/>(méthodes surchargées dans le parser spécifique)"]
  GEN --> EX

ÉTAPE 1 : Analyser Format Email Banque

1.1 Récupérer Échantillons

Obtenez minimum 3 emails de transaction de la nouvelle banque couvrant: - Opération CREDIT - Opération DEBIT - Formats différents si applicable

1.2 Identifier Type Email

HTML ou PDF ?

  • HTML: Email contient données dans le corps (inspecter source HTML)
  • PDF: Email contient pièce jointe PDF

1.3 Mapper Champs

Identifiez où se trouvent les données dans l'email:

Donnée Requise Où dans l'email ? Exemple
proforma_reference Champ "Motif", "Référence", etc. "ATT-JOE-123"
description Champ "Libellé", "Description" "VIREMENT RECU"
account_number Numéro compte "00012345678"
amount Montant "1,500,000.00"
currency Devise "XAF"
transaction_date Date transaction "12-Nov-2025"
operation_type Type (Débit/Crédit) "Débité"

⚠️ IMPORTANT: Notez le mapping spécifique de la banque. Parfois le champ "Description" contient la référence et vice-versa!

1.4 Exemple : AFG Bank

1
2
3
4
5
6
7
Email AFG Bank:
  La description       → "DEPOT D ESPECES - KAMGA BOGNE..."
  Numéro de réference  → "012CHDP251811504"

Mapping AFG:
  proforma_reference ← "La description" (texte long)
  description        ← "Numéro de réference" (code court)

ÉTAPE 2 : Créer Parser Spécifique

2.1 Créer Fichier Parser

Chemin: backend/app/services/parsers/xyz_bank_parser.py

Remplacez xyz par nom banque (ex: ecobank_parser.py, bicec_parser.py)

2.2 Template Parser HTML

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
"""
XYZ Bank Parser - Parser spécifique [Nom Banque]

Mapping spécifique XYZ Bank:
- proforma_reference = [Décrire mapping]
- description = [Décrire mapping]
"""
import re
from typing import Optional, Dict
from email.message import Message
from bs4 import BeautifulSoup
import logging

from app.services.parsers.html_parser import HTMLParser

logger = logging.getLogger(__name__)


class XYZBankParser(HTMLParser):
    """
    Parser spécifique [Nom Banque] (HTML)

    Hérite de HTMLParser mais override les méthodes d'extraction
    pour respecter le mapping spécifique de la banque.
    """

    def __init__(self):
        super().__init__()
        logger.debug("XYZBankParser initialized")

    # OPTIONNEL: Override parse() seulement si logique différente
    # Sinon, hériter de HTMLParser.parse() et juste override méthodes extract

    def _extract_proforma_reference(self, text: str) -> Optional[str]:
        """
        Extrait proforma_reference selon format XYZ Bank

        Adaptez le pattern regex selon votre email
        """
        # Exemple: Chercher "Motif : XXXXX"
        match = re.search(
            r'Motif\s*:?\s*(.+?)(?:\n|$)',
            text,
            re.IGNORECASE
        )
        if match:
            proforma = match.group(1).strip()
            proforma = re.sub(r'\s+', ' ', proforma)  # Nettoyer espaces
            if len(proforma) > 5:  # Validation minimum
                logger.debug(f"XYZ proforma extracted: {proforma[:50]}...")
                return proforma

        logger.warning("No XYZ proforma found")
        return None

    def _extract_description(self, text: str) -> Optional[str]:
        """
        Extrait description selon format XYZ Bank
        """
        # Exemple: Chercher "Libellé : XXXXX"
        match = re.search(
            r'Libell[ée]\s*:?\s*(.+?)(?:\n|$)',
            text,
            re.IGNORECASE
        )
        if match:
            description = match.group(1).strip()
            description = re.sub(r'\s+', ' ', description)
            if description:
                logger.debug(f"XYZ description extracted: {description[:50]}...")
                return description

        logger.warning("No XYZ description found")
        return None

    # OPTIONNEL: Override autres méthodes si format spécial
    # def _extract_amount(self, text: str) -> Optional[float]:
    #     # Logique spécifique extraction montant
    #     pass

2.3 Template Parser PDF

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
"""
XYZ Bank Parser - Parser spécifique [Nom Banque] (PDF)
"""
import re
from typing import Optional, Dict
from email.message import Message
import logging

from app.services.parsers.pdf_parser import PDFParser

logger = logging.getLogger(__name__)


class XYZBankParser(PDFParser):
    """
    Parser spécifique [Nom Banque] (PDF)
    """

    def __init__(self):
        super().__init__()
        logger.debug("XYZBankParser initialized")

    def _extract_proforma_reference(self, text: str) -> Optional[str]:
        """
        Extrait proforma depuis PDF XYZ Bank

        text contient le texte extrait du PDF avec pdfplumber
        """
        # Adaptez selon structure PDF
        match = re.search(
            r'R[ée]f[ée]rence\s*:?\s*([A-Z0-9-]+)',
            text,
            re.IGNORECASE
        )
        if match:
            ref = match.group(1).strip()
            logger.debug(f"XYZ proforma: {ref}")
            return ref
        return None

    def _extract_description(self, text: str) -> Optional[str]:
        """
        Extrait description depuis PDF
        """
        match = re.search(
            r'Description\s*:?\s*(.+?)(?:\n|$)',
            text,
            re.IGNORECASE
        )
        if match:
            desc = match.group(1).strip()
            return desc
        return None

2.4 Conseils Patterns Regex

Patterns courants:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Montant avec séparateurs
r'Montant\s*:?\s*([\d\s,\.]+)'

# Date formats variés
r'(\d{1,2}[-/]\w{3}[-/]\d{4})'  # 12-Nov-2025
r'(\d{2}/\d{2}/\d{4})'          # 12/11/2025

# Opération type
r'(D[ée]bit|Cr[ée]dit)'

# Compte
r'Compte\s*:?\s*([\d\-]+)'

# Texte jusqu'au prochain champ
r'Motif\s*(.+?)(?=\n\s*Type|$)'  # Capturer jusqu'à "Type" ou fin

Regex tips: - \s* : 0+ espaces/newlines - \s+ : 1+ espaces/newlines - .+? : Capturer minimum (non-greedy) - (?:\n|$) : Jusqu'à newline ou fin - re.IGNORECASE : Insensible casse - re.DOTALL : . matche aussi newlines


ÉTAPE 3 : Enregistrer Parser

3.1 Exporter dans __init__.py

Fichier: backend/app/services/parsers/__init__.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Ajouter import
from app.services.parsers.xyz_bank_parser import XYZBankParser

# Ajouter à __all__
__all__ = [
    'BaseParser',
    'HTMLParser',
    'PDFParser',
    'AFGBankParser',
    'SGBankParser',
    'XYZBankParser'  # ← AJOUTER ICI
]

3.2 Ajouter Import Dynamique

Fichier: backend/app/services/email_processor.py

Méthode: _get_parser()

1
2
3
4
5
6
7
8
9
def _get_parser(self, bank):
    if bank.parser_class:
        # ... code existant ...

        # AJOUTER VOTRE BANQUE:
        elif bank.parser_class == "XYZBankParser":
            from app.services.parsers.xyz_bank_parser import XYZBankParser
            parser = XYZBankParser()
        # ... reste du code ...

Position exacte: Après les blocs AFGBankParser et SGBankParser, avant else:.


ÉTAPE 4 : Configuration Base de Données

4.1 Ajouter Banque

SQL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
INSERT INTO banks (
    name,
    email_pattern,
    payment_source,
    payment_method,
    reference_bank,
    processing_method,
    parser_class,
    is_active
) VALUES (
    'XYZ Bank Cameroun',              -- Nom banque
    'notifications@xyzbank.cm',        -- Pattern email (ou '*@xyzbank.cm')
    'XYZ-BANK-CM-237',                -- Label source paiement
    'BANK_DEPOSIT',                    -- Méthode paiement
    'XYZ_BANK_REF',                   -- Référence banque (optionnel)
    'EMAIL_BODY',                      -- ou 'PDF_ATTACHMENT'
    'XYZBankParser',                   -- ← NOM CLASSE PARSER
    TRUE                               -- Actif
);

Via Python:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from app.database import SessionLocal
from app.models.bank import Bank, ProcessingMethodEnum

db = SessionLocal()

new_bank = Bank(
    name='XYZ Bank Cameroun',
    email_pattern='notifications@xyzbank.cm',
    payment_source='XYZ-BANK-CM-237',
    payment_method='BANK_DEPOSIT',
    reference_bank='XYZ_BANK_REF',
    processing_method=ProcessingMethodEnum.EMAIL_BODY,  # ou PDF_ATTACHMENT
    parser_class='XYZBankParser',  # ← IMPORTANT
    is_active=True
)

db.add(new_bank)
db.commit()

print(f"Banque ajoutée: ID={new_bank.id}")

db.close()

4.2 Vérifier Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from app.database import SessionLocal
from app.models.bank import Bank

db = SessionLocal()

bank = db.query(Bank).filter(Bank.name == 'XYZ Bank Cameroun').first()

print(f"Nom: {bank.name}")
print(f"Email Pattern: {bank.email_pattern}")
print(f"Processing Method: {bank.processing_method.value}")
print(f"Parser Class: {bank.parser_class}")

db.close()

ÉTAPE 5 : Tests

5.1 Test Parser Isolé

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from app.services.email_client import EmailClient
from app.services.parsers.xyz_bank_parser import XYZBankParser
from email import message_from_bytes

client = EmailClient()
client.connect()

# Récupérer email test
status, messages = client.connection.search(None, '(FROM "notifications@xyzbank.cm")')
email_ids = messages[0].split()

if len(email_ids) > 0:
    email_id = email_ids[0]
    status, msg_data = client.connection.fetch(email_id, '(RFC822)')

    for response_part in msg_data:
        if isinstance(response_part, tuple):
            email_msg = message_from_bytes(response_part[1])

            # Tester parser
            parser = XYZBankParser()
            data = parser.parse(email_msg)

            if data:
                print("✓ Parse OK")
                print(f"Proforma: {data.get('proforma_reference')}")
                print(f"Description: {data.get('description')}")
                print(f"Amount: {data.get('amount')}")
                print(f"Currency: {data.get('currency')}")
            else:
                print("✗ Parse FAILED")

client.disconnect()

5.2 Test Workflow Complet

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from app.services.email_processor import EmailProcessor
from app.database import SessionLocal

db = SessionLocal()
processor = EmailProcessor(db)

# Tester avec email XYZ
email_msg = ...  # Message email
sender_email = "notifications@xyzbank.cm"

transaction_id = processor.process_email(email_msg, sender_email)

if transaction_id:
    from app.models.transaction import Transaction
    tx = db.query(Transaction).filter(Transaction.id == transaction_id).first()

    print(f"✓ Transaction créée: ID={tx.id}")
    print(f"  Proforma: {tx.proforma_reference}")
    print(f"  Description: {tx.description}")
    print(f"  Status: {tx.status.value}")
else:
    print("✗ Échec création transaction")

db.close()

5.3 Checklist Validation

  • Parser extrait proforma_reference correctement
  • Parser extrait description correctement
  • Parser extrait amount > 0
  • Parser extrait currency (XAF, EUR, etc.)
  • Parser extrait transaction_date valide
  • Parser extrait operation_type (DEBIT/CREDIT)
  • Transaction créée avec status PENDING_WEBHOOK (si proforma présente)
  • Transaction créée avec status MISSING_PROFORMA_REF (si proforma absente)
  • Métadonnées sender/email_subject renseignées
  • Reference_boaz générée (REF_BOAZ_TRANS_XXX)

Exemples Réels

Exemple 1 : AFG Bank (HTML, mapping inversé)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class AFGBankParser(HTMLParser):
    def _extract_afg_proforma(self, text: str) -> Optional[str]:
        # AFG: proforma = "La description" (texte long)
        match = re.search(
            r'La\s+description\s*(.+?)\s*Type\s+de\s+transaction',
            text,
            re.IGNORECASE | re.DOTALL
        )
        if match:
            description = match.group(1).strip()
            description = re.sub(r'\s+', ' ', description)
            if len(description) > 15:
                return description
        return None

    def _extract_afg_description(self, text: str) -> Optional[str]:
        # AFG: description = "Numéro de réference" (code court)
        match = re.search(
            r'Num[ée]ro\s+de\s+r[ée]f[ée]rence\s*:?\s*([A-Z0-9-]+)',
            text,
            re.IGNORECASE
        )
        if match:
            return match.group(1).strip()
        return None

Exemple 2 : SG Bank (PDF, standard)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class SGBankParser(PDFParser):
    def _extract_proforma_reference(self, text: str) -> Optional[str]:
        # SG: proforma = "Motif" (code court)
        match = re.search(r'Motif\s*:?\s*([A-Z0-9-]+)', text, re.IGNORECASE)
        if match:
            motif = match.group(1).strip()
            if re.match(r'^[A-Z0-9-]{3,}$', motif):
                return motif
        return None

    def _extract_description(self, text: str) -> Optional[str]:
        # SG: description = "Libellé" (texte descriptif)
        match = re.search(
            r'Libell[ée]\s*:?\s*(.+?)(?:\n|$)',
            text,
            re.IGNORECASE
        )
        if match:
            libelle = match.group(1).strip()
            return re.sub(r'\s+', ' ', libelle)
        return None

Dépannage

Parser ne trouve pas les données

Problème: No XYZ proforma found

Solutions: 1. Vérifier pattern regex avec email réel 2. Inspecter texte extrait: print(text) dans méthode 3. Utiliser regex online tester (regex101.com) 4. Vérifier encoding (UTF-8, ISO-8859-1)

Transaction status MISSING_PROFORMA_REF

Problème: Toutes transactions ont ce status

Solutions: 1. Vérifier _extract_proforma_reference() retourne valeur 2. Vérifier longueur minimum respectée 3. Logger proforma extraite: logger.debug(f"Proforma: {proforma}")

Parser non chargé

Problème: Parser générique utilisé au lieu du spécifique

Solutions: 1. Vérifier bank.parser_class en DB (exact spelling) 2. Vérifier import dynamique dans _get_parser() 3. Redémarrer backend: docker compose restart backend 4. Vérifier logs: docker compose logs backend | grep "Loaded bank-specific"

Erreur "value too long"

Problème: value too long for type character varying(50)

Solutions: 1. Vérifier taille colonne DB (proforma_reference doit être VARCHAR(255)) 2. Créer migration si nécessaire pour agrandir colonne


Récapitulatif 4 Étapes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌─────────────────────────────────────────────────────┐
│ ÉTAPE 1: Analyser Format Email                     │
│ ✓ Récupérer 3+ échantillons                        │
│ ✓ Identifier HTML ou PDF                           │
│ ✓ Mapper champs (proforma, description, etc.)      │
└─────────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────────┐
│ ÉTAPE 2: Créer Parser Spécifique                   │
│ ✓ Créer xyz_bank_parser.py                         │
│ ✓ Hériter HTMLParser ou PDFParser                  │
│ ✓ Override _extract_proforma_reference()           │
│ ✓ Override _extract_description()                  │
└─────────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────────┐
│ ÉTAPE 3: Enregistrer Parser                        │
│ ✓ Exporter dans parsers/__init__.py                │
│ ✓ Ajouter import dynamique dans EmailProcessor     │
└─────────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────────┐
│ ÉTAPE 4: Configuration DB                          │
│ ✓ INSERT INTO banks (..., parser_class='XYZBank')  │
│ ✓ Vérifier config                                  │
│ ✓ Tester avec emails réels                         │
└─────────────────────────────────────────────────────┘

Temps total: 30-45 minutes


Support

Questions fréquentes:

Q: Puis-je utiliser parser générique sans créer parser spécifique? R: Oui! Laissez parser_class=NULL en DB. Le système utilisera HTMLParser ou PDFParser générique.

Q: Que faire si banque a plusieurs formats d'email? R: Créez logique dans parser pour détecter format et adapter extraction.

Q: Comment débugger patterns regex? R: Utilisez logger.debug() pour afficher texte extrait et résultats intermédiaires.

Q: Parser peut hériter d'un autre parser spécifique? R: Oui, mais généralement mieux hériter de HTMLParser/PDFParser directement.


Version: 1.0 Dernière mise à jour: 2025-11-12 Testé avec: AFG Bank, Société Générale Cameroun