info@altius-group.ch
Froideville, Waadt
DE

One bank entry, forty invoices

/ 11 Min. Lesezeit / aktualisiert 04.09.2026

Every business in Switzerland receives an ISO 20022 file from its bank. Since the QR-bill replaced the orange ESR slip in October 2022, the payment advice that used to arrive as a V11 file arrives as camt.054, and the end-of-day statement as camt.053.

Both are XML, both are specified in several hundred pages, and both contain a structural trap that quietly breaks reconciliation.

Here it is in one sentence:

A camt.054 advice does not contain forty payments. It contains one booking that contains forty payments.

Your bank credits your account once, for the day’s total. Inside that single <Ntry> are forty <TxDtls> elements, each with its own amount, its own payer and its own QR reference. A parser that reads the entry amount produces one movement of CHF 12‘450.00 that matches no invoice in your ledger — and forty invoices that stay open.

This is the ISO 20022 parser in AltiusOne, the fiduciary and accounting platform we run for Swiss mandates. Four hundred lines, and most of them exist because of that sentence.

Two formats, one parser, and the reason they belong together

"""
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.
"""

camt.054 is the format of grouped QR payment advices, successor to the V11/ESR. Each TxDtls becomes a distinct movement, individually reconcilable with its invoice.

The two documents answer different questions — what is my balance and who paid me — and they share the Acct / Ntry structure below the root. One parser, one auto-detection step, and the differences handled where they actually differ:

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")

A comment records the one asymmetry that matters:

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

An advice has no balances. A camt.054 tells you what arrived, not what you have. Code that reads Bal unconditionally gets an opening and closing balance of zero and, if anything compares them, reports a discrepancy for every advice ever imported.

The version is in the namespace

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 puts the message version in the namespace URI. A file conforming to camt.053.001.04 and one conforming to .08 are, to an XML parser, documents in two unrelated vocabularies. Every XPath expression bound to one returns nothing against the other.

This is the failure that hits you without warning, because you do not upgrade — your bank does. One morning the file arrives as version 08, every find() returns None, and the import reports “Stmt element not found”, which reads exactly like a corrupt file. The customer is told their bank sent something invalid. It did not.

Seven versions of each format, generated rather than typed, so adding version 09 is one number. And the detection is done properly:

@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

Two strategies, because banks disagree about where to declare the namespace. Most put it on the root <Document>; some declare it on the child. The fallback searches for the known child element in each candidate namespace — slower, and only reached when the fast path fails.

And an unrecognised namespace returns a named error, not an exception:

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

The error travels in the result object, so the import screen can say this file is not a camt we know — which is actionable — rather than showing a traceback for a file the accountant downloaded from e-banking five minutes ago.

Splitting the batch

This is the heart of it.

@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 returns a list. That is the design decision, made at the signature: one <Ntry> maps to zero, one or many movements, and every caller has to deal with that. A function returning a single entry would have to be rediscovered as wrong later, by which point the callers all assume one-to-one.

Three cases, and the third is what makes it safe.

Zero or one TxDtls — an ordinary payment, a card transaction, a standing order. The entry-level amount is the transaction amount. Details are merged in if present.

Several TxDtls — a batch. Each transaction becomes its own movement, carrying its own amount, its own currency, its own credit/debit direction and its own reference. Forty QR payments become forty rows, each matchable against one invoice. This is the entire point of the file.

A batch whose transactions have no usable amount — fall back to the aggregate, and say so in the log.

That third branch is worth dwelling on:

# ... jamais de montants inventés.

Never invented amounts.

The tempting repair is to divide the entry total by the number of transactions, or to assign the remainder to the last one. Both produce a file that imports cleanly and a ledger that is wrong — and wrong in accounting is not a display bug, it is a figure someone will file with a tax authority.

So the parser degrades to something true but less useful: one aggregate movement that the accountant will have to split by hand. They will notice, because one movement of CHF 12‘450.00 does not match any invoice, and noticing is the correct outcome. The log line carries the bank reference so the file can be found.

When a parser cannot determine a number, it must produce a coarser truth, not a plausible fabrication. Anything that reconciles silently and wrongly is worse than an import the accountant has to finish by hand.

Note replace(base, ...) from dataclasses: each transaction starts from a copy of the entry-level fields — booking date, value date, bank reference, which the batch shares — and overrides only what is per-transaction. Mutating base in the loop would have every entry end up with the last transaction’s values, which is the classic version of this bug and passes any test with one transaction in it.

sens_tx is only applied when present. A batch can contain both credits and debits, so the direction is per-transaction where the file says so, and inherited from the entry where it does not.

Money is Decimal, and the currency is in the file

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) — constructed from the string, never through float. float("1234.55") is not 1234.55, and a rounding error in a bank reconciliation is a discrepancy someone spends an afternoon on.

InvalidOperation returns None for the entry, which _parse_entries turns into an empty list. An unparseable amount drops the movement rather than importing a zero — and a zero-franc movement in a bank import is exactly the kind of thing that gets reconciled against a rounding difference and hides the real problem.

And the currency is read from the file, from the Ccy attribute on Amt. Not assumed to be CHF. A Swiss company routinely holds EUR and USD accounts, receives a camt for each, and imports them into the same system. The warning on a missing Ccy is the right level: the file is unusual, the movement is still usable, and somebody should look.

Balances get the sign treatment:

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 never uses a negative number. Every amount is positive, and a sibling CdtDbtInd element says which direction it goes. A parser that reads amounts without reading the indicator gets a statement where every debit looks like a credit — and the totals still balance, which is why it survives review.

OPBD and CLBD — opening and closing booked balance — are two of several balance types in the same file. PRCD, ITBD, FWAV and others may also be present. Selecting by code rather than by position is the difference between a correct closing balance and whichever <Bal> happened to come last.

Two lines of defence on a user-uploaded file

"""
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")

The file arrives by upload, from a person, through a browser. Parsing it with the standard library’s ElementTree gives an attacker who can persuade an accountant to import a file both XXE — read any file the process can read, make requests from inside your network — and billion laughs — a few hundred bytes that expand to gigabytes and kill the worker.

The justification for refusing outright is stated and it is the right one: no legitimate camt contains them. This is not a heuristic. The ISO 20022 schemas do not use entity declarations, so refusing them rejects nothing real.

And a second, different bound:

# 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 stops entity expansion; it does not stop a genuinely enormous but perfectly valid file. Ten thousand transactions is well above a month of statements for an SME and well below what would exhaust memory, and the check is inside the loop — the parse stops when the bound is crossed, rather than building the whole list and then measuring it.

The reference to the Power of Ten is not decoration: rule 2 is all loops must have a fixed upper bound, and an import loop over an uploaded document is exactly the case it was written for.

Why this matters more in Switzerland than elsewhere

The QR-bill made this structural problem universal here.

Under the old ESR/BVR system, a business received a V11 file — a flat, fixed-width list, one line per payment, each with its reference number. Parsing it was tedious and the shape was obvious: lines in, payments out.

The QR-bill’s camt.054 replacement is richer and hierarchical, and the hierarchy is where the mistake lives. The bank still groups the day’s collections into one booking, because that is what appears on your account. The detail is one level down. Software written against a single test file — one payment, one entry, one transaction — works perfectly and fails the first day two customers pay on the same day.

Which is the failure mode worth naming for anyone integrating a hierarchical financial format:

Test with a batch. The one-element case and the many-element case are different code paths in the file format, and the one-element case is what your bank sends you when you ask for a sample.

For a fiduciary, the difference is measured in hours per client per month: either the QR references arrive attached to their amounts and the invoices close themselves, or somebody opens the e-banking detail view and types.

What to take away

  1. One <Ntry> is not one payment. Split by TxDtls, and design the parse function to return a list from the start.
  2. The ISO 20022 version lives in the namespace. Support a range, generate it, and detect at both the root and the child level — your bank will upgrade without telling you.
  3. Never invent an amount. Fall back to a coarser truth and log it; an import that reconciles wrongly is worse than one an accountant has to finish.
  4. Copy the shared fields, override the per-transaction ones. Mutating the base in the loop passes every single-transaction test.
  5. Decimal from the string, currency from the Ccy attribute, sign from CdtDbtInd. ISO 20022 amounts are always positive.
  6. Select balances by code (OPBD, CLBD), never by position.
  7. defusedxml plus a hard transaction bound. One stops entity attacks, the other stops a large valid file, and you need both.
  8. Test with a batch file, because the sample your bank gives you has one transaction in it.
Bereit loszulegen?

Sprechen wir über Ihr Projekt

Erzählen Sie uns von Ihrem Bedarf in IoT, GIS oder individueller Entwicklung — wir melden uns innerhalb von 24 Stunden.