Skip to content

About

Normalisation de relevés bancaires en Python : CSV (profils YAML), OFX 1.x et 2.x, CAMT.053 vers un schéma unique, dédoublonnage et rapport des rejets.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

bank-statement-normalizer

Lire des relevés bancaires hétérogènes (CSV de toutes formes, OFX 1.x et 2.x, CAMT.053 ISO 20022) et produire une seule table de transactions propre, dédoublonnée et validée, avec un rapport clair de tout ce qui a été écarté.

Bibliothèque Python + outil en ligne de commande releve.

Projet open source personnel, en version 0.1. Il n'est pas publié sur PyPI et n'a pas été éprouvé en production : c'est une base de travail documentée et testée.

Sommaire

Le problème

Un cabinet comptable reçoit chaque mois les relevés de ses clients dans des formats très différents :

  • des CSV qui ne se ressemblent jamais : séparateur ;, , ou tabulation, encodage UTF-8 (avec ou sans BOM) ou Windows-1252, lignes de titre avant les colonnes, montants 1 234,56, 12,00- ou 1.234,56, une colonne de montant signé ou deux colonnes Débit / Crédit, dates JJ/MM/AAAA, AAAA-MM-JJ ou JJ.MM.AA ;
  • des fichiers OFX, en version 1.x (SGML, balises non fermées) ou 2.x (XML) ;
  • des relevés CAMT.053 (ISO 20022), dans plusieurs versions de schéma.

Les exports se chevauchent (janvier-mars puis mars-mai), contiennent des lignes cassées, des pieds de page, des IBAN mal saisis. Ressaisir ou retraiter tout cela à la main est lent et source d'erreurs.

L'objectif de ce projet est un moteur d'ingestion extensible : un nouveau format CSV s'ajoute avec un fichier YAML, un format réellement nouveau avec une seule classe, et aucune ligne invalide ne fait échouer le traitement.

Fonctionnalités

  • Détection automatique du format par le contenu (pas par l'extension) : OFX 1.x, OFX 2.x, CAMT.053 (toute version camt.053.001.xx), CSV reconnu grâce à sa ligne d'en-tête.
  • Profils CSV en YAML : séparateur, encodage, lignes à ignorer, correspondance des colonnes, format de date, séparateur décimal, montant signé ou Débit / Crédit, date de valeur et solde optionnels, extraction de l'IBAN dans le préambule. Sélection automatique du profil, ou forçage avec --profile.
  • Registre de parseurs (@register_parser) et points d'entrée Python (bank_statement_normalizer.parsers) pour ajouter un format sans modifier le code.
  • Modèle pydantic v2 : montants en Decimal signé (jamais de float), devise ISO 4217, IBAN compacté et validé (clé mod 97).
  • Robustesse : une ligne invalide est rejetée avec son motif, jamais le fichier entier ; BOM, Windows-1252, espaces insécables, lignes vides finales, champs entre guillemets contenant le séparateur ou un retour à la ligne.
  • Dédoublonnage entre exports qui se chevauchent, grâce à un transaction_id stable, sans perdre deux opérations réellement identiques le même jour.
  • Sorties : CSV UTF-8 (option --excel-fr pour Excel français), JSON, Parquet (extra optionnel pyarrow), rapport de rejets JSON, résumé par compte.

Installation

Prérequis : Python 3.11 ou plus récent. Les commandes ci-dessous utilisent uv, mais pip fonctionne aussi.

git clone https://github.com/adh-studio/bank-statement-normalizer.git
cd bank-statement-normalizer
uv sync                    # dépendances + outils de développement
uv sync --all-extras       # idem, avec la sortie Parquet (pyarrow)
uv run releve --help

Avec pip, dans un environnement virtuel :

pip install .              # dépendances minimales : pydantic, typer, rich, pyyaml
pip install ".[parquet]"   # ajoute pyarrow pour la sortie Parquet
releve --help

L'outil est aussi utilisable avec python -m bank_statement_normalizer.

Utilisation en ligne de commande

Les fichiers d'exemple de examples/entrees/ sont synthétiques (noms fictifs, IBAN fictifs à clé valide) et contiennent volontairement quelques lignes défectueuses. Toutes les sorties ci-dessous ont été produites par l'outil, sans retouche.

releve detect : quel format pour chaque fichier ?

$ releve detect examples/entrees/*
                                           Détection
┌───────────────────────────────────────────────────┬─────────┬───────────────────┬───────────┐
│ Fichier                                           │ Format  │ Profil / variante │ Confiance │
├───────────────────────────────────────────────────┼─────────┼───────────────────┼───────────┤
│ examples/entrees/compte_ofx1_sgml.ofx             │ ofx     │ OFX 1.x (SGML)    │ 100%      │
│ examples/entrees/compte_ofx2_xml.ofx              │ ofx     │ OFX 2.x (XML)     │ 100%      │
│ examples/entrees/export_banque_c.csv              │ csv     │ exemple_banque_c  │ 90%       │
│ examples/entrees/export_banque_d.tsv              │ csv     │ exemple_banque_d  │ 90%       │
│ examples/entrees/releve_banque_a_juin.csv         │ csv     │ exemple_banque_a  │ 90%       │
│ examples/entrees/releve_banque_a_juin_juillet.csv │ csv     │ exemple_banque_a  │ 90%       │
│ examples/entrees/releve_banque_b_juillet.csv      │ csv     │ exemple_banque_b  │ 90%       │
│ examples/entrees/releve_camt053_v02.xml           │ camt053 │ camt.053.001.02   │ 100%      │
│ examples/entrees/releve_camt053_v08.xml           │ camt053 │ camt.053.001.08   │ 100%      │
└───────────────────────────────────────────────────┴─────────┴───────────────────┴───────────┘

La confiance est une priorité entre détecteurs : une signature explicite (en-tête OFX, espace de noms CAMT) vaut 100 %, un en-tête CSV reconnu 90 %, un profil imposé par --profile 50 %. Un fichier OFX renommé en .txt est donc toujours lu comme OFX.

releve convert : normaliser, dédoublonner, écrire

$ releve convert examples/entrees/* -o examples/sortie/transactions.csv --report examples/sortie/rapport_rejets.json
                                             Fichiers traités
┌───────────────────────────────────────────────────┬─────────┬───────────────────┬──────────────┬────────┐
│ Fichier                                           │ Format  │ Profil / variante │ Transactions │ Rejets │
├───────────────────────────────────────────────────┼─────────┼───────────────────┼──────────────┼────────┤
│ examples/entrees/compte_ofx1_sgml.ofx             │ ofx     │ OFX 1.x (SGML)    │ 4            │ 1      │
│ examples/entrees/compte_ofx2_xml.ofx              │ ofx     │ OFX 2.x (XML)     │ 3            │ 1      │
│ examples/entrees/export_banque_c.csv              │ csv     │ exemple_banque_c  │ 5            │ 1      │
│ examples/entrees/export_banque_d.tsv              │ csv     │ exemple_banque_d  │ 5            │ 0      │
│ examples/entrees/releve_banque_a_juin.csv         │ csv     │ exemple_banque_a  │ 9            │ 0      │
│ examples/entrees/releve_banque_a_juin_juillet.csv │ csv     │ exemple_banque_a  │ 8            │ 1      │
│ examples/entrees/releve_banque_b_juillet.csv      │ csv     │ exemple_banque_b  │ 7            │ 3      │
│ examples/entrees/releve_camt053_v02.xml           │ camt053 │ camt.053.001.02   │ 3            │ 1      │
│ examples/entrees/releve_camt053_v08.xml           │ camt053 │ camt.053.001.08   │ 3            │ 0      │
└───────────────────────────────────────────────────┴─────────┴───────────────────┴──────────────┴────────┘
                                                Résumé par compte
┌─────────────────────────────┬────────┬──────────────────────────┬────────────┬──────────┬───────────┬──────────┐
│ Compte                      │ Devise │ Période                  │ Opérations │ Crédits  │ Débits    │ Net      │
├─────────────────────────────┼────────┼──────────────────────────┼────────────┼──────────┼───────────┼──────────┤
│ 00012345678                 │ EUR    │ 03/08/2026 au 20/08/2026 │ 4          │ 2 750,00 │ -192,40   │ 2 557,60 │
│ 4970XXXXXXXX1234            │ EUR    │ 04/08/2026 au 15/08/2026 │ 3          │ 32,40    │ -121,40   │ -89,00   │
│ FR1499999000020000000005678 │ EUR    │ 01/07/2026 au 20/07/2026 │ 7          │ 3 760,00 │ -1 245,34 │ 2 514,66 │
│ FR2899999000040000000003456 │ EUR    │ 03/08/2026 au 19/08/2026 │ 5          │ 1 890,00 │ -1 154,50 │ 735,50   │
│ FR3099999000010000000001234 │ EUR    │ 02/06/2026 au 08/07/2026 │ 13         │ 4 750,00 │ -2 466,19 │ 2 283,81 │
│ FR7630006000011234567890189 │ EUR    │ 04/08/2026 au 11/08/2026 │ 3          │ 1 500,00 │ -400,18   │ 1 099,82 │
│ FR8899999000050000000007890 │ EUR    │ 03/08/2026 au 28/08/2026 │ 3          │ 4 200,00 │ -1 192,90 │ 3 007,10 │
│ FR9299999000030000000009012 │ EUR    │ 01/07/2026 au 09/07/2026 │ 4          │ 1 865,90 │ -1 064,35 │ 801,55   │
│ FR9299999000030000000009012 │ USD    │ 06/07/2026 au 06/07/2026 │ 1          │ 0,00     │ -49,00    │ -49,00   │
└─────────────────────────────┴────────┴──────────────────────────┴────────────┴──────────┴───────────┴──────────┘
                             Rejets (8)
┌────────────────────────────┬────────────────────────────┬────────┐
│ Motif                      │ Code                       │ Nombre │
├────────────────────────────┼────────────────────────────┼────────┤
│ Date invalide              │ date_invalide              │ 2      │
│ Montant invalide           │ montant_invalide           │ 2      │
│ Champ obligatoire manquant │ champ_manquant             │ 1      │
│ Écriture non comptabilisée │ ecriture_non_comptabilisee │ 1      │
│ IBAN invalide              │ iban_invalide              │ 1      │
│ Débit et crédit renseignés │ montant_ambigu             │ 1      │
└────────────────────────────┴────────────────────────────┴────────┘
  [date_invalide] examples/entrees/releve_banque_a_juin_juillet.csv, ligne 7 : date illisible : '31/06/2026' (format attendu : %d/%m/%Y)
  [date_invalide] examples/entrees/releve_banque_b_juillet.csv, ligne 15 : date illisible : 'Solde au 31/07/2026' (format attendu : %d/%m/%Y)
  [montant_invalide] examples/entrees/compte_ofx2_xml.ofx, ligne 52 : montant illisible : 'ABC'
  [montant_invalide] examples/entrees/releve_banque_b_juillet.csv, ligne 13 : montant illisible : '12,3,4'
  [champ_manquant] examples/entrees/compte_ofx1_sgml.ofx, ligne 65 : DTPOSTED absent
  [ecriture_non_comptabilisee] examples/entrees/releve_camt053_v02.xml, Stmt 1, Ntry 4 : statut PDNG (seules les écritures BOOK)
  [iban_invalide] examples/entrees/export_banque_c.csv, ligne 7 : account : clé de contrôle IBAN invalide (mod 97) : FR9999999000030000000009012
  [montant_ambigu] examples/entrees/releve_banque_b_juillet.csv, ligne 12 : débit (10,00) et crédit (10,00) renseignés sur la même ligne
Doublons ignorés (présents dans plusieurs exports) : 4
Rapport de rejets : examples/sortie/rapport_rejets.json
43 transactions écrites dans examples/sortie/transactions.csv (csv).

À lire dans cette sortie :

  • les deux exports de la banque A se chevauchent du 15 au 18 juin : les 4 opérations communes ne sont gardées qu'une fois, mais les deux cafés identiques du 16 juin restent bien deux transactions ;
  • le 31/06/2026 (date impossible), le pied de page Solde au ..., le montant 12,3,4 (ambigu, donc refusé plutôt que deviné) et l'IBAN à clé fausse sont rejetés un par un, le reste de chaque fichier est traité ;
  • l'écriture CAMT au statut PDNG (en attente) est écartée, seules les écritures BOOK sont comptabilisées ;
  • le compte FR92... apparaît deux fois car le résumé est calculé par compte et par devise.

Début du fichier produit (examples/sortie/transactions.csv) :

transaction_id,account,booking_date,value_date,label,amount,currency,reference,balance,source_format,source_file,source_position,occurrence
d7ab1f9583b29f1c7d07,00012345678,2026-08-03,,CB SUPERMARCHE EXEMPLE PAIEMENT CARTE 02/08,-64.90,EUR,OFX20260803001,,ofx,examples/entrees/compte_ofx1_sgml.ofx,ligne 40,1
3647d201092b1c737d1a,00012345678,2026-08-05,2026-08-06,VIR RECU CABINET EXEMPLE HONORAIRES JUILLET,2750.00,EUR,OFX20260805001,,ofx,examples/entrees/compte_ofx1_sgml.ofx,ligne 49,1

Le dossier examples/sortie/ contient les sorties complètes : transactions.csv, transactions_excel_fr.csv, transactions.json, transactions.parquet et rapport_rejets.json. Un test vérifie que les sorties CSV et JSON correspondent exactement à ce que produit le code actuel.

releve profiles et releve formats

$ releve profiles
                                                           Profils CSV
┌──────────────────┬────────────────────────────────────────┬───────────────┬──────────┬────────────────┬───────────────────────┐
│ Nom              │ Description                            │ Séparateur    │ Encodage │ Montant        │ Origine               │
├──────────────────┼────────────────────────────────────────┼───────────────┼──────────┼────────────────┼───────────────────────┤
│ exemple_banque_a │ Montant signé, compte par ligne, UTF-8 │ point-virgule │ utf-8    │ montant signé  │ exemple_banque_a.yaml │
│ exemple_banque_b │ Débit / Crédit séparés, IBAN dans le   │ point-virgule │ cp1252   │ débit / crédit │ exemple_banque_b.yaml │
│                  │ préambule, Windows-1252                │               │          │                │                       │
│ exemple_banque_c │ Séparateur virgule, point décimal,     │ virgule       │ utf-8    │ montant signé  │ exemple_banque_c.yaml │
│                  │ dates ISO, référence                   │               │          │                │                       │
│ exemple_banque_d │ Tabulation, signe moins en fin de      │ tabulation    │ auto     │ montant signé  │ exemple_banque_d.yaml │
│                  │ montant, dates JJ.MM.AAAA              │               │          │                │                       │
└──────────────────┴────────────────────────────────────────┴───────────────┴──────────┴────────────────┴───────────────────────┘

$ releve formats
                              Formats d'entrée
┌─────────┬─────────────────────────────────────────────────────────────────┐
│ Nom     │ Description                                                     │
├─────────┼─────────────────────────────────────────────────────────────────┤
│ camt053 │ ISO 20022 CAMT.053 (toutes versions camt.053.001.xx)            │
│ csv     │ Export CSV décrit par un profil YAML (voir « releve profiles ») │
│ ofx     │ OFX 1.x (SGML) et 2.x (XML)                                     │
└─────────┴─────────────────────────────────────────────────────────────────┘

Important : les quatre profils fournis (exemple_banque_a à exemple_banque_d) sont illustratifs. Ils décrivent des mises en page plausibles de CSV bancaires français, pas le format officiel d'une banque réelle. Pour vos propres exports, écrivez un profil (voir plus bas) : c'est un fichier YAML de quelques lignes.

Options de releve convert

Option Effet
-o, --output FICHIER Fichier de sortie (obligatoire).
-f, --format csv|json|parquet Format de sortie. Par défaut : déduit de l'extension, sinon csv.
-p, --profile NOM Impose un profil CSV aux fichiers qui ne sont ni OFX ni CAMT.
--profiles-dir DOSSIER Ajoute des profils YAML (option répétable). Un profil de même nom remplace le profil intégré.
--excel-fr CSV pour Excel français : séparateur ;, virgule décimale, BOM UTF-8.
--report FICHIER Écrit le rapport de rejets en JSON (compte par motif et exemples).
--max-examples N Nombre d'exemples par motif de rejet (3 par défaut).
--no-dedup Désactive le dédoublonnage.
--strict Code retour 1 dès qu'une ligne est rejetée (utile en CI ou en chaîne automatisée).

Codes retour : 0 succès ; 1 aucun fichier exploitable, ou rejet en mode --strict ; 2 erreur de configuration (profil inconnu ou invalide, pyarrow absent pour Parquet). releve detect renvoie 1 si un fichier n'est pas reconnu.

Utilisation en bibliothèque

from pathlib import Path

from bank_statement_normalizer import normalize_files

result = normalize_files(sorted(Path("examples/entrees").glob("releve_banque_a_*.csv")))
print(
    len(result.transactions),
    "transactions,",
    len(result.duplicates),
    "doublons,",
    len(result.rejections),
    "rejet(s)",
)
t = result.transactions[0]
print(t.transaction_id, t.booking_date, t.amount, repr(t.label))
for s in result.summaries():
    print(s.account, s.count, s.total_credits, s.total_debits, s.net)
for r in result.rejections:
    print(r.reason.value, r.position, r.detail)

Sortie :

13 transactions, 4 doublons, 1 rejet(s)
de501fb4f6f1a0b1c616 2026-06-02 1250.00 'VIR SEPA RECU CLIENT DUPONT FACTURE 2026-041'
FR3099999000010000000001234 13 4750.00 -2466.19 2283.81
date_invalide ligne 7 date illisible : '31/06/2026' (format attendu : %d/%m/%Y)

Autres points d'entrée utiles :

  • normalize_files(paths, profile=..., profiles_dirs=[...], deduplicate=True) ;
  • write_transactions(transactions, path, "csv" | "json" | "parquet", excel_fr=False) ;
  • result.report(max_examples=5) : rapport de rejets (modèle pydantic, sérialisable en JSON) ;
  • detect_format(Source.from_path(path), make_context()) : détection seule ;
  • Transaction : modèle pydantic immuable, model_dump(mode="json") pour l'exporter.

normalize_files ne lève pas d'exception pour un fichier ou une ligne défectueux : tout finit dans result.rejections. Seules les erreurs de configuration (profil inconnu ou invalide) lèvent ProfileError.

Schéma normalisé

Champ Type Description
transaction_id str (20 hexa) Identifiant stable, calculé à partir des champs métier (voir Dédoublonnage).
account str ou vide IBAN compacté et validé (mod 97) quand il est disponible, sinon l'identifiant de compte fourni (ex. ACCTID OFX).
booking_date date Date de comptabilisation.
value_date date ou vide Date de valeur.
label str Libellé, espaces normalisés.
amount Decimal Montant signé : négatif pour un débit. Jamais de float.
currency str Code ISO 4217 (EUR, USD...).
reference str ou vide Référence bancaire : FITID (OFX), AcctSvcrRef ou NtryRef (CAMT), colonne du profil (CSV).
balance Decimal ou vide Solde après opération, si l'export le fournit (CSV).
source_format str csv, ofx, camt053 ou le nom d'un parseur ajouté.
source_file str Chemin du fichier tel que fourni, en notation POSIX.
source_position str Position lisible : ligne 12 (CSV, OFX), Stmt 1, Ntry 3 (CAMT).
occurrence int Rang parmi les transactions identiques d'un même fichier (1 en général).

En JSON et en CSV, les montants sont écrits sous forme de texte ("-12.50") pour conserver leur valeur décimale exacte. En Parquet, ils sont en decimal128(38, n) et les dates en date32.

Correspondance des champs sources :

Champ CSV (profil) OFX CAMT.053
booking_date colonne booking_date DTPOSTED BookgDt/Dt ou BookgDt/DtTm
value_date colonne value_date DTAVAIL ValDt
amount amount, ou debit et credit TRNAMT Amt et CdtDbtInd (DBIT donne un montant négatif)
currency colonne currency, sinon currency du profil CURDEF du relevé attribut Amt/@Ccy, sinon Acct/Ccy
label une ou plusieurs colonnes concaténées NAME (ou PAYEE/NAME) puis MEMO AddtlNtryInf puis les RmtInf/Ustrd
reference colonne reference FITID AcctSvcrRef, sinon NtryRef
account colonne, account_pattern sur le préambule ou account fixe BANKACCTFROM/ACCTID ou CCACCTFROM/ACCTID Acct/Id/IBAN ou Acct/Id/Othr/Id

Architecture

flowchart LR
    F["Fichiers"] --> S["Source : octets + décodage (BOM, UTF-8, cp1252)"]
    S --> D{"detect_format : chaque parseur enregistré donne un score"}
    D -->|"csv + profil YAML"| C["CsvParser"]
    D -->|"ofx"| O["OfxParser"]
    D -->|"camt053"| X["Camt053Parser"]
    D -->|"extension"| P["Votre parseur"]
    C & O & X & P --> T["Parser.parse : validation pydantic, erreurs isolées par ligne, rang d'occurrence"]
    T --> R["Rejets avec motif"]
    T --> DD["Dédoublonnage entre fichiers"]
    DD --> W["CSV / JSON / Parquet"]
    DD --> SUM["Résumé par compte"]
Loading

Chaque parseur ne fait que trois choses : reconnaître son format (detect), découper le fichier en enregistrements bruts (records) et convertir un enregistrement en ParsedEntry (convert). Tout le reste est commun et écrit une seule fois dans Parser.parse (patron « méthode template ») : validation, capture des erreurs ligne par ligne, métadonnées de provenance, rang d'occurrence.

Module Rôle
base.py Contrat Parser, RawRecord, Detection, ParserContext, boucle parse() commune.
registry.py @register_parser, chargement des points d'entrée, detect_format.
parsers/csv_profile.py Lecteur CSV générique piloté par un profil.
parsers/ofx.py Analyseur tolérant OFX 1.x (SGML) et 2.x (XML), sans DTD.
parsers/camt053.py CAMT.053 via xml.etree, espaces de noms retirés, DTD refusées.
profiles.py + profiles/*.yaml Modèle CsvProfile, chargement et validation des profils.
models.py Transaction, ParsedEntry, Rejection (pydantic v2).
values.py, iban.py, source.py Montants, dates, IBAN, encodages.
dedup.py, summary.py, report.py, writers.py Dédoublonnage, résumé, rapport, sorties.
pipeline.py normalize_files : assemble le tout, ne plante jamais sur un fichier.
cli.py Commande releve (typer + rich).

Ajouter un nouveau format

C'est le cœur du projet : absorber un nouveau format ou un retour utilisateur sans toucher au moteur.

Cas 1 : un nouvel export CSV (aucun code)

Un client envoie un CSV que l'outil ne reconnaît pas (releve detect affiche inconnu). Il suffit d'écrire un profil :

  1. Créer un dossier, par exemple mes_profils/, et y placer banque_client.yaml :

    name: banque_client
    description: "Export CSV de la banque du client X"
    delimiter: ";"
    encoding: cp1252          # ou utf-8, ou auto (UTF-8 puis cp1252)
    skip_lines: 2             # lignes avant la ligne d'en-tête
    date_format: "%d/%m/%Y"   # ou une liste : ["%d/%m/%Y", "%d/%m/%y"]
    decimal_separator: ","
    currency: EUR             # devise par défaut si aucune colonne devise
    account_pattern: "Compte n° (?P<account>FR[0-9 ]+)"   # optionnel
    columns:
      booking_date: "Date"
      value_date: "Valeur"          # optionnel
      label: ["Libellé", "Détail"]  # une colonne ou plusieurs, concaténées
      debit: "Débit"                # soit debit + credit...
      credit: "Crédit"
      # amount: "Montant"           # ...soit une seule colonne signée
      reference: "Référence"        # optionnel
      balance: "Solde"              # optionnel
  2. Vérifier la détection : releve detect --profiles-dir mes_profils export.csv.

  3. Convertir : releve convert --profiles-dir mes_profils export.csv -o sortie.csv.

Le profil est validé au chargement (clé inconnue, amount et debit ensemble, expression régulière invalide... donnent un message d'erreur explicite). La correspondance des en-têtes ignore la casse, les accents et les espaces superflus.

Cas 2 : un format réellement nouveau (une classe)

Exemple : l'export JSON d'une néobanque fictive.

{"iban": "FR76 3000 6000 0112 3456 7890 189",
 "operations": [{"id": "op-1", "date": "2026-09-01", "montant": -12.5, "libelle": "Abonnement"}]}
  1. Écrire la classe en héritant de Parser et en la décorant avec @register_parser :

    import json
    from collections.abc import Iterator
    from typing import Any
    
    from bank_statement_normalizer import (
        Detection,
        ParsedEntry,
        Parser,
        ParserContext,
        RawRecord,
        Source,
        register_parser,
    )
    from bank_statement_normalizer.values import parse_amount, parse_compact_date
    
    
    @register_parser
    class NeobanqueJsonParser(Parser[dict[str, Any]]):
        """Export JSON d'une néobanque fictive : {"iban": "...", "operations": [...]}."""
    
        name = "neobanque_json"
        description = "Export JSON d'une néobanque (exemple)"
    
        @classmethod
        def detect(cls, source: Source, context: ParserContext) -> Detection | None:
            head = source.text.lstrip()[:500]
            if head.startswith("{") and '"operations"' in head:
                return Detection(cls.name, 0.9, details="JSON néobanque")
            return None
    
        def records(self) -> Iterator[RawRecord[dict[str, Any]]]:
            document = json.loads(self.source.text)
            self.iban = document.get("iban")
            for index, operation in enumerate(document["operations"], start=1):
                yield RawRecord(
                    position=f"opération {index}",
                    payload=operation,
                    excerpt=json.dumps(operation, ensure_ascii=False),
                )
    
        def convert(self, payload: dict[str, Any]) -> ParsedEntry:
            return ParsedEntry(
                account=self.iban,
                booking_date=parse_compact_date(payload["date"]),
                amount=parse_amount(str(payload["montant"]), decimal_separator="."),
                currency=payload.get("devise", "EUR"),
                label=payload.get("libelle", ""),
                reference=payload.get("id"),
            )
  2. Importer le module avant d'appeler normalize_files (le décorateur enregistre la classe). La détection, la validation, les rejets, le dédoublonnage, le résumé et les trois formats de sortie fonctionnent immédiatement.

  3. Gérer les erreurs sans try/except : parse_amount et parse_compact_date lèvent RecordRejectedError avec le bon motif ; toute autre exception dans convert (ici un KeyError si date manque) est rattrapée et rapportée comme enregistrement_invalide, sans arrêter le fichier. Pour un motif précis, levez RecordRejectedError(RejectionReason.MISSING_FIELD, "...").

  4. Ajouter un test : tests/test_plugin_example.py exécute exactement ce code sur un fichier contenant une bonne opération, un montant corrompu et une date absente.

  5. Distribuer comme extension (optionnel) : dans un paquet séparé, déclarer un point d'entrée ; releve formats l'affichera sans rien modifier ici.

    [project.entry-points."bank_statement_normalizer.parsers"]
    neobanque_json = "mon_paquet.neobanque:NeobanqueJsonParser"

Dédoublonnage : stratégie et limites

Les exports d'un même compte se chevauchent souvent. Chaque transaction reçoit un transaction_id : les 20 premiers caractères hexadécimaux du SHA-256 de la clé suivante.

Élément de la clé Détail
compte IBAN compacté, ou identifiant de compte
date de comptabilisation la date de valeur n'entre pas dans la clé
montant forme canonique : -4.2 et -4.20 donnent la même clé
devise
référence ou libellé la référence bancaire si elle existe (FITID, AcctSvcrRef, colonne), sinon le libellé normalisé (majuscules, sans accents, espaces simplifiés)
rang d'occurrence 1, 2, 3... parmi les transactions identiques d'un même fichier

Le rang d'occurrence règle le cas classique des deux opérations réellement identiques le même jour (deux cafés à 4,20 €) : dans un fichier elles reçoivent les rangs 1 et 2 et restent deux transactions. Si un export en contient deux et l'export suivant trois, le résultat en contient trois. Entre fichiers, la première occurrence rencontrée est conservée ; --no-dedup désactive l'étape.

Limites, à connaître avant de s'y fier :

  • Même transaction, libellé différent : si la banque enrichit le libellé entre deux exports (opération en attente puis comptabilisée, libellé tronqué différemment) et qu'il n'y a pas de référence, les deux versions sont gardées.
  • Date de comptabilisation qui change entre deux exports : doublon non détecté.
  • Formats différents pour le même compte (un CSV et un OFX sur la même période) : en général non dédoublonnés, car l'OFX porte un FITID et le CSV n'a pas de référence, ou des libellés composés autrement.
  • Référence non fiable : si une banque réutilise ses références, ou en change pour la même opération, le dédoublonnage fusionne ou manque des transactions.
  • Doublon présent dans un seul fichier (bug d'export qui répète une ligne) : il est conservé comme deuxième occurrence, car rien ne le distingue d'une vraie deuxième opération.
  • Compte inconnu : un CSV sans information de compte donne account vide ; deux comptes différents sans compte renseigné peuvent alors entrer en collision. Renseignez le compte (colonne, account_pattern ou account dans le profil).

Les transactions écartées comme doublons restent accessibles dans result.duplicates pour contrôle.

Robustesse et rapport de rejets

Aucune ligne défectueuse n'arrête un fichier, aucun fichier défectueux n'arrête le traitement. Chaque rejet porte un motif stable, la position et un extrait brut.

Code Motif Exemples
date_invalide Date invalide 31/06/2026, pied de page Solde au ...
montant_invalide Montant invalide ABC, 12,3,4, CdtDbtInd inconnu
champ_manquant Champ obligatoire manquant DTPOSTED absent, ni débit ni crédit
montant_ambigu Débit et crédit renseignés les deux colonnes remplies sur la même ligne
nombre_de_colonnes Nombre de colonnes incorrect séparateur non protégé dans un libellé
iban_invalide IBAN invalide clé mod 97 fausse, longueur incorrecte
devise_invalide Devise invalide EURO au lieu de EUR
ecriture_non_comptabilisee Écriture non comptabilisée statut CAMT PDNG ou INFO
enregistrement_invalide Enregistrement mal formé CSV cassé, erreur inattendue d'un parseur
fichier_illisible Fichier illisible XML mal formé, colonnes absentes, fichier introuvable
format_inconnu Format non reconnu aucun parseur ni profil ne correspond

Formats de nombres acceptés (séparateur décimal ,) : 1 234,56 (espace, espace insécable ou espace fine), 1.234,56, -12,00, 12,00-, +12,00, (12,00), 12,00 €. Les groupes de milliers doivent faire trois chiffres : une valeur ambiguë est rejetée plutôt que devinée.

Limites connues

  • Profils d'exemple illustratifs : ils ne correspondent pas aux formats officiels de banques réelles. Il faut écrire un profil par format d'export rencontré.
  • Détection CSV par l'en-tête uniquement : deux profils aux en-têtes identiques (mais aux formats de date différents, par exemple) ne peuvent pas être distingués. Le profil qui décrit le plus de colonnes l'emporte, puis l'ordre alphabétique ; utilisez --profile dans ce cas. La ligne d'en-tête doit se trouver exactement après skip_lines lignes.
  • OFX : ACCTID est conservé tel quel (pas de reconstitution d'IBAN à partir du RIB) ; les agrégats CURRENCY / ORIGCURRENCY sont ignorés (le montant est supposé dans la devise CURDEF) ; la date de valeur vient de DTAVAIL, DTUSER n'est pas utilisé ; les relevés de placements (INVSTMTRS) ne sont pas lus. L'analyseur est volontairement tolérant et ne valide pas le fichier contre la DTD OFX.
  • CAMT.053 : une écriture Ntry donne une transaction (les écritures groupées avec plusieurs TxDtls ne sont pas éclatées) ; seuls les statuts BOOK (ou absents) sont retenus ; les soldes Bal ne sont pas lus ; camt.052 et camt.054 ne sont pas pris en charge ; les fichiers contenant une DTD ou des entités sont refusés par sécurité.
  • Pas de rapprochement des soldes (solde d'ouverture + mouvements = solde de clôture) : ce serait un contrôle utile à ajouter.
  • Un solde illisible rejette la ligne entière, même si le montant est correct : choix de prudence, discutable.
  • Tout est chargé en mémoire : adapté à des relevés de quelques dizaines de milliers de lignes, pas à du flux massif.
  • Les textes d'aide génériques de Click (--help, [required]) restent en anglais.

Développement

uv sync --all-extras               # environnement complet (dev + pyarrow)
uv run ruff check .                # lint
uv run ruff format --check .       # formatage
uv run mypy src                    # typage strict
uv run pytest --cov                # tests + couverture (seuil minimal : 90 %)

Régénérer les sorties d'exemple après une modification du comportement :

uv run releve convert examples/entrees/* -o examples/sortie/transactions.csv --report examples/sortie/rapport_rejets.json
uv run releve convert examples/entrees/* -o examples/sortie/transactions.json
uv run releve convert examples/entrees/* -o examples/sortie/transactions_excel_fr.csv --excel-fr
uv run releve convert examples/entrees/* -o examples/sortie/transactions.parquet

Les tests couvrent chaque parseur sur des fichiers synthétiques (lignes mal formées, OFX SGML et XML, CAMT .02 / .04 / .08 / .10, CSV Windows-1252, colonnes Débit / Crédit et montant signé), le dédoublonnage entre exports qui se chevauchent, les sorties, le registre de parseurs et la ligne de commande de bout en bout. La CI GitHub Actions (.github/workflows/ci.yml) exécute les mêmes contrôles sous Python 3.11, 3.12 et 3.13.

Toutes les données du dépôt sont fictives : noms inventés, IBAN construits sur un code banque 99999 inexistant (ou IBAN d'exemple usuels), avec une clé de contrôle valide.

Licence

MIT, voir LICENSE. Copyright 2026 Henintsoa Andriamaholy.

About

Normalisation de relevés bancaires en Python : CSV (profils YAML), OFX 1.x et 2.x, CAMT.053 vers un schéma unique, dédoublonnage et rapport des rejets.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages