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.
- Le problème
- Fonctionnalités
- Installation
- Utilisation en ligne de commande
- Utilisation en bibliothèque
- Schéma normalisé
- Architecture
- Ajouter un nouveau format
- Dédoublonnage : stratégie et limites
- Robustesse et rapport de rejets
- Limites connues
- Développement
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, montants1 234,56,12,00-ou1.234,56, une colonne de montant signé ou deux colonnes Débit / Crédit, datesJJ/MM/AAAA,AAAA-MM-JJouJJ.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.
- 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
Decimalsigné (jamais defloat), 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_idstable, sans perdre deux opérations réellement identiques le même jour. - Sorties : CSV UTF-8 (option
--excel-frpour Excel français), JSON, Parquet (extra optionnelpyarrow), rapport de rejets JSON, résumé par compte.
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 --helpAvec pip, dans un environnement virtuel :
pip install . # dépendances minimales : pydantic, typer, rich, pyyaml
pip install ".[parquet]" # ajoute pyarrow pour la sortie Parquet
releve --helpL'outil est aussi utilisable avec python -m bank_statement_normalizer.
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 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 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 pageSolde au ..., le montant12,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 écrituresBOOKsont 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,1Le 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
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.
| 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.
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.
| 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 |
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"]
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). |
C'est le cœur du projet : absorber un nouveau format ou un retour utilisateur sans toucher au moteur.
Un client envoie un CSV que l'outil ne reconnaît pas (releve detect affiche
inconnu). Il suffit d'écrire un profil :
-
Créer un dossier, par exemple
mes_profils/, et y placerbanque_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
-
Vérifier la détection :
releve detect --profiles-dir mes_profils export.csv. -
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.
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"}]}-
Écrire la classe en héritant de
Parseret 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"), )
-
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. -
Gérer les erreurs sans try/except :
parse_amountetparse_compact_datelèventRecordRejectedErroravec le bon motif ; toute autre exception dansconvert(ici unKeyErrorsidatemanque) est rattrapée et rapportée commeenregistrement_invalide, sans arrêter le fichier. Pour un motif précis, levezRecordRejectedError(RejectionReason.MISSING_FIELD, "..."). -
Ajouter un test :
tests/test_plugin_example.pyexécute exactement ce code sur un fichier contenant une bonne opération, un montant corrompu et une date absente. -
Distribuer comme extension (optionnel) : dans un paquet séparé, déclarer un point d'entrée ;
releve formatsl'affichera sans rien modifier ici.[project.entry-points."bank_statement_normalizer.parsers"] neobanque_json = "mon_paquet.neobanque:NeobanqueJsonParser"
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
accountvide ; deux comptes différents sans compte renseigné peuvent alors entrer en collision. Renseignez le compte (colonne,account_patternouaccountdans le profil).
Les transactions écartées comme doublons restent accessibles dans
result.duplicates pour contrôle.
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.
- 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
--profiledans ce cas. La ligne d'en-tête doit se trouver exactement aprèsskip_lineslignes. - OFX :
ACCTIDest conservé tel quel (pas de reconstitution d'IBAN à partir du RIB) ; les agrégatsCURRENCY/ORIGCURRENCYsont ignorés (le montant est supposé dans la deviseCURDEF) ; la date de valeur vient deDTAVAIL,DTUSERn'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
Ntrydonne une transaction (les écritures groupées avec plusieursTxDtlsne sont pas éclatées) ; seuls les statutsBOOK(ou absents) sont retenus ; les soldesBalne 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.
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.parquetLes 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.
MIT, voir LICENSE. Copyright 2026 Henintsoa Andriamaholy.