info@altius-group.ch
Froideville, Vaud
FR

Une écriture bancaire, quarante factures

/ 12 min de lecture / mis à jour 04.09.2026

Toute entreprise suisse reçoit un fichier ISO 20022 de sa banque. Depuis que la QR-facture a remplacé le bulletin de versement orange en octobre 2022, l’avis d’encaissement qui arrivait en fichier V11 arrive en camt.054, et le relevé de fin de journée en camt.053.

Les deux sont du XML, les deux sont spécifiés sur plusieurs centaines de pages, et les deux portent un piège de structure qui casse silencieusement le rapprochement.

Le voici en une phrase :

Un avis camt.054 ne contient pas quarante paiements. Il contient une écriture qui contient quarante paiements.

Votre banque crédite votre compte une fois, du total de la journée. À l’intérieur de cette unique balise <Ntry> se trouvent quarante <TxDtls>, chacune avec son montant, son payeur et sa référence QR. Un parseur qui lit le montant de l’écriture produit un mouvement de CHF 12 450.00 qui ne correspond à aucune facture de votre comptabilité — et quarante factures qui restent ouvertes.

Voici l’analyseur ISO 20022 d’AltiusOne, la plateforme fiduciaire et comptable que nous exploitons pour des mandats suisses. Quatre cents lignes, dont la plupart n’existent qu’à cause de cette phrase.

Deux formats, un parseur, et pourquoi ils vont ensemble

"""
Service de parsing des fichiers bancaires ISO 20022 :

- camt.053 (relevé de compte fin de journée, BkToCstmrStmt/Stmt) ;
- camt.054 (avis de débit/crédit détaillé, BkToCstmrDbtCdtNtfctn/Ntfctn) —
  c'est le format des avis d'encaissement QR groupés (successeur du V11/ESR) :
  une écriture de lot (Ntry) contient N transactions (TxDtls) portant chacune
  son montant et sa référence QR. Chaque TxDtls devient un mouvement distinct,
  rapprochable individuellement avec sa facture.
"""

Les deux documents répondent à des questions différentes — quel est mon solde et qui m’a payé — et partagent la structure Acct / Ntry sous la racine. Un seul parseur, une détection automatique, et les différences traitées là où elles existent vraiment :

if kind == 'camt.054':
    stmt_elem = root.find('.//ns:BkToCstmrDbtCdtNtfctn/ns:Ntfctn', nsmap)
    if stmt_elem is None:
        return CamtStatement(error="Element Ntfctn non trouve dans le fichier")
else:
    stmt_elem = root.find('.//ns:BkToCstmrStmt/ns:Stmt', nsmap)
    if stmt_elem is None:
        return CamtStatement(error="Element Stmt non trouve dans le fichier")

Un commentaire consigne la seule asymétrie qui compte :

# camt.053 : BkToCstmrStmt/Stmt ; camt.054 : BkToCstmrDbtCdtNtfctn/Ntfctn
# (même structure Acct/Ntry, mais pas de soldes Bal dans un avis 054).

Un avis ne porte pas de soldes. Un camt.054 dit ce qui est arrivé, pas ce que vous avez. Du code qui lit Bal sans condition obtient un solde d’ouverture et de clôture à zéro et, si quelque chose les compare, signale un écart pour chaque avis jamais importé.

La version est dans l’espace de noms

SUPPORTED_NAMESPACES = [
    f'urn:iso:std:iso:20022:tech:xsd:camt.053.001.{v:02d}'
    for v in range(2, 9)
]
SUPPORTED_NAMESPACES_054 = [
    f'urn:iso:std:iso:20022:tech:xsd:camt.054.001.{v:02d}'
    for v in range(2, 9)
]

ISO 20022 place la version du message dans l’URI de l’espace de noms. Un fichier conforme à camt.053.001.04 et un fichier conforme au .08 sont, pour un analyseur XML, des documents écrits dans deux vocabulaires sans rapport. Toute expression XPath liée à l’un ne renvoie rien contre l’autre.

C’est la panne qui tombe sans prévenir, parce que ce n’est pas vous qui montez de version — c’est votre banque. Un matin le fichier arrive en version 08, tous les find() renvoient None, et l’import annonce « élément Stmt introuvable », ce qui se lit exactement comme un fichier corrompu. On dit au client que sa banque a envoyé quelque chose d’invalide. Ce n’est pas le cas.

Sept versions de chaque format, engendrées plutôt que saisies : ajouter la version 09 est un chiffre. Et la détection est faite proprement :

@staticmethod
def _detect_namespace(root):
    tag = root.tag
    if '{' in tag:
        ns = tag.split('}')[0].lstrip('{')
        if ns in CamtParserService.SUPPORTED_NAMESPACES:
            return ns, 'camt.053'
        if ns in CamtParserService.SUPPORTED_NAMESPACES_054:
            return ns, 'camt.054'

    # Chercher dans les enfants (namespace declare plus bas)
    for supported_ns in CamtParserService.SUPPORTED_NAMESPACES:
        if root.find(f'{{{supported_ns}}}BkToCstmrStmt') is not None:
            return supported_ns, 'camt.053'
    ...
    return None, None

Deux stratégies, parce que les banques ne s’accordent pas sur l’endroit où déclarer l’espace de noms. La plupart le posent sur la racine <Document> ; certaines le déclarent sur l’enfant. Le repli cherche l’élément enfant connu dans chaque espace de noms candidat — plus lent, et atteint seulement quand la voie rapide échoue.

Et un espace de noms non reconnu rend une erreur nommée, pas une exception :

ns, kind = CamtParserService._detect_namespace(root)
if not ns:
    return CamtStatement(error="Namespace camt.053/camt.054 non reconnu")

L’erreur voyage dans l’objet de résultat, donc l’écran d’import peut dire ce fichier n’est pas un camt que nous connaissons — ce sur quoi on peut agir — plutôt que d’afficher une trace d’exécution pour un fichier que le comptable a téléchargé de son e-banking cinq minutes plus tôt.

Éclater le lot

C’est le cœur.

@staticmethod
def _parse_entries(ntry_elem, nsmap):
    """Une écriture simple (0 ou 1 TxDtls) donne une entry ; une écriture de
    lot (N TxDtls — typique des avis camt.054 d'encaissements QR groupés)
    donne une entry PAR transaction, chacune avec son montant et sa
    référence propres. Si une transaction du lot n'a pas de montant
    exploitable, on retombe sur l'écriture agrégée (jamais de montants
    inventés).
    """
    base = CamtParserService._parse_entry_level(ntry_elem, nsmap)
    if base is None:
        return []

    tx_list = ntry_elem.findall('ns:NtryDtls/ns:TxDtls', nsmap)
    if len(tx_list) <= 1:
        if tx_list:
            CamtParserService._apply_tx_details(base, tx_list[0], nsmap)
        return [base]

    entries = []
    for tx_dtls in tx_list:
        amount, currency = CamtParserService._tx_amount(tx_dtls, nsmap)
        if amount is None:
            logger.warning(
                "Lot camt sans montant par transaction — repli sur "
                "l'écriture agrégée (ref bancaire %s)", base.bank_reference,
            )
            agrege = replace(base)
            CamtParserService._apply_tx_details(agrege, tx_list[0], nsmap)
            return [agrege]
        entry = replace(base, amount=amount, currency=currency or base.currency)
        sens_tx = tx_dtls.findtext('ns:CdtDbtInd', '', nsmap)
        if sens_tx:
            entry.credit_debit = sens_tx
        CamtParserService._apply_tx_details(entry, tx_dtls, nsmap)
        entries.append(entry)
    return entries

_parse_entries renvoie une liste. C’est la décision de conception, prise dans la signature : une <Ntry> correspond à zéro, un ou plusieurs mouvements, et tout appelant doit le savoir. Une fonction qui renverrait une seule écriture devrait être redécouverte comme fausse plus tard, quand tous les appelants supposent déjà un pour un.

Trois cas, et le troisième est ce qui rend l’ensemble sûr.

Zéro ou une TxDtls — un paiement ordinaire, une transaction par carte, un ordre permanent. Le montant de niveau écriture est le montant de la transaction. Les détails sont fusionnés s’ils existent.

Plusieurs TxDtls — un lot. Chaque transaction devient son propre mouvement, avec son montant, sa devise, son sens et sa référence. Quarante paiements QR donnent quarante lignes, chacune rapprochable d’une facture. C’est tout l’objet du fichier.

Un lot dont les transactions n’ont pas de montant exploitable — repli sur l’agrégat, et le journal le dit.

Cette troisième branche mérite qu’on s’y arrête :

# ... jamais de montants inventés.

La réparation tentante consiste à diviser le total de l’écriture par le nombre de transactions, ou à attribuer le reliquat à la dernière. Les deux produisent un fichier qui s’importe proprement et une comptabilité fausse — et faux en comptabilité n’est pas un défaut d’affichage, c’est un chiffre que quelqu’un déposera auprès d’une administration fiscale.

Le parseur dégrade donc vers quelque chose de vrai mais moins utile : un mouvement agrégé que le comptable devra éclater à la main. Il le remarquera, parce qu’un mouvement de CHF 12 450.00 ne correspond à aucune facture, et le remarquer est le bon résultat. La ligne de journal porte la référence bancaire pour retrouver le fichier.

Quand un parseur ne peut pas déterminer un montant, il doit produire une vérité plus grossière, jamais une fabrication plausible. Tout ce qui rapproche silencieusement et faussement est pire qu’un import que le comptable doit terminer à la main.

Notez replace(base, ...) de dataclasses : chaque transaction part d’une copie des champs de niveau écriture — date de comptabilisation, date de valeur, référence bancaire, que le lot partage — et ne surcharge que ce qui est propre à la transaction. Muter base dans la boucle donnerait à toutes les écritures les valeurs de la dernière transaction, ce qui est la version classique de ce défaut et passe n’importe quel test contenant une seule transaction.

sens_tx n’est appliqué que s’il est présent. Un lot peut porter des crédits et des débits : le sens est donc pris par transaction là où le fichier le dit, et hérité de l’écriture là où il ne le dit pas.

L’argent est un Decimal, et la devise est dans le fichier

amt_elem = ntry_elem.find('ns:Amt', nsmap)
if amt_elem is not None and amt_elem.text:
    try:
        entry.amount = Decimal(amt_elem.text)
    except InvalidOperation:
        return None
    entry.currency = amt_elem.get('Ccy', '')
    if not entry.currency:
        logger.warning("Attribut Ccy absent du XML camt — devise non déterminée")

Decimal(amt_elem.text) — construit depuis la chaîne, jamais en passant par float. float("1234.55") ne vaut pas 1234.55, et une erreur d’arrondi dans un rapprochement bancaire est un écart sur lequel quelqu’un passe un après-midi.

InvalidOperation renvoie None pour l’écriture, ce que _parse_entries transforme en liste vide. Un montant illisible fait tomber le mouvement plutôt que d’importer un zéro — et un mouvement à zéro franc dans un import bancaire est exactement le genre de chose qu’on rapproche d’une différence d’arrondi, ce qui masque le vrai problème.

Et la devise est lue dans le fichier, depuis l’attribut Ccy de Amt. Pas supposée CHF. Une société suisse détient couramment des comptes EUR et USD, reçoit un camt pour chacun, et les importe dans le même système. Le warning sur un Ccy absent est au bon niveau : le fichier est inhabituel, le mouvement reste utilisable, et quelqu’un devrait regarder.

Les soldes reçoivent le traitement du signe :

amount = Decimal(amt_elem.text)
if cd_elem == 'DBIT':
    amount = -amount

if bal_type == 'OPBD':
    statement.opening_balance = amount
elif bal_type == 'CLBD':
    statement.closing_balance = amount

ISO 20022 n’emploie jamais de nombre négatif. Tout montant est positif, et un élément frère CdtDbtInd dit dans quel sens il va. Un parseur qui lit les montants sans lire l’indicateur obtient un relevé où chaque débit ressemble à un crédit — et les totaux s’équilibrent quand même, ce qui explique qu’il survive à la relecture.

OPBD et CLBD — soldes comptabilisés d’ouverture et de clôture — sont deux des plusieurs types de solde présents dans le même fichier. PRCD, ITBD, FWAV et d’autres peuvent aussi s’y trouver. Sélectionner par code plutôt que par position fait la différence entre un solde de clôture correct et le <Bal> qui se trouvait en dernier.

Deux lignes de défense sur un fichier téléversé

"""
Parsing via defusedxml (fichiers uploadés par l'utilisateur : neutralise
les bombes d'entités type billion-laughs et toute résolution d'entité externe).
"""
except DefusedXmlException:
    # Entités/DTD interdites (billion-laughs, XXE) : aucun camt
    # légitime n'en contient.
    return CamtStatement(error="XML rejeté: déclarations DTD/entités interdites")

Le fichier arrive par téléversement, d’une personne, à travers un navigateur. L’analyser avec l’ElementTree de la bibliothèque standard offre à qui saura convaincre un comptable d’importer un fichier à la fois XXE — lire n’importe quel fichier accessible au processus, émettre des requêtes depuis l’intérieur de votre réseau — et billion laughs — quelques centaines d’octets qui se déploient en gigaoctets et tuent le processus.

La justification du refus pur et simple est écrite, et c’est la bonne : aucun camt légitime n’en contient. Ce n’est pas une heuristique. Les schémas ISO 20022 n’emploient pas de déclarations d’entités, donc les refuser ne rejette rien de réel.

Et une seconde borne, de nature différente :

# Borne dure sur le volume d'un fichier uploadé (Power of 10, règle 2).
MAX_TRANSACTIONS = 10_000

...

for ntry_elem in stmt_elem.findall('ns:Ntry', nsmap):
    entries = CamtParserService._parse_entries(ntry_elem, nsmap)
    statement.entries.extend(entries)
    if len(statement.entries) > CamtParserService.MAX_TRANSACTIONS:
        return CamtStatement(
            error=f"Fichier rejeté: plus de "
                  f"{CamtParserService.MAX_TRANSACTIONS} transactions"
        )

defusedxml arrête l’expansion d’entités ; il n’arrête pas un fichier véritablement énorme mais parfaitement valide. Dix mille transactions, c’est bien au-dessus d’un mois de relevés pour une PME et bien en dessous de ce qui épuiserait la mémoire. Et le contrôle est à l’intérieur de la boucle : l’analyse s’arrête quand la borne est franchie, au lieu de construire toute la liste avant de la mesurer.

La référence au Power of Ten n’est pas décorative : la règle 2 est toute boucle doit avoir une borne supérieure fixe, et une boucle d’import sur un document téléversé est exactement le cas pour lequel elle a été écrite.

Pourquoi cela compte plus en Suisse qu’ailleurs

La QR-facture a rendu ce problème de structure universel ici.

Sous l’ancien système BVR/ESR, une entreprise recevait un fichier V11 — une liste plate, à largeur fixe, une ligne par paiement, chacune avec son numéro de référence. L’analyser était fastidieux et la forme était évidente : des lignes entrent, des paiements sortent.

Le camt.054 qui l’a remplacé est plus riche et hiérarchique, et c’est dans la hiérarchie que loge la faute. La banque groupe toujours les encaissements du jour en une écriture, parce que c’est ce qui apparaît sur votre compte. Le détail est un niveau plus bas. Un logiciel écrit contre un seul fichier d’essai — un paiement, une écriture, une transaction — fonctionne parfaitement et échoue le premier jour où deux clients paient à la même date.

D’où le mode de défaillance à retenir pour quiconque intègre un format financier hiérarchique :

Testez avec un lot. Le cas à un élément et le cas à plusieurs sont deux chemins distincts dans le format, et le cas à un élément est précisément ce que votre banque vous envoie quand vous demandez un échantillon.

Pour un fiduciaire, l’écart se mesure en heures par client et par mois : soit les références QR arrivent attachées à leurs montants et les factures se soldent seules, soit quelqu’un ouvre le détail de l’e-banking et saisit.

Ce qu’il faut en retenir

  1. Une <Ntry> n’est pas un paiement. Éclatez par TxDtls, et concevez la fonction d’analyse pour rendre une liste dès le départ.
  2. La version ISO 20022 vit dans l’espace de noms. Supportez une plage, engendrez-la, et détectez à la racine comme sur l’enfant — votre banque montera de version sans vous prévenir.
  3. N’inventez jamais un montant. Repliez-vous sur une vérité plus grossière et journalisez ; un import qui rapproche faussement est pire qu’un import que le comptable doit terminer.
  4. Copiez les champs partagés, surchargez ceux de la transaction. Muter la base dans la boucle passe tous les tests à une seule transaction.
  5. Decimal depuis la chaîne, devise depuis l’attribut Ccy, signe depuis CdtDbtInd. Les montants ISO 20022 sont toujours positifs.
  6. Sélectionnez les soldes par code (OPBD, CLBD), jamais par position.
  7. defusedxml plus une borne dure de transactions. L’un arrête les attaques par entités, l’autre un gros fichier valide, et il faut les deux.
  8. Testez avec un fichier de lot, parce que l’échantillon que votre banque vous donne contient une seule transaction.
Prêt à démarrer ?

Parlons de votre projet

Dites-nous vos besoins en IoT, SIG ou développement sur mesure — nous vous répondons sous 24 h.