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
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:
A comment records the one asymmetry that matters:
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
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:
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:
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.
_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:
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
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:
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
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:
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
- One
<Ntry>is not one payment. Split byTxDtls, and design the parse function to return a list from the start. - 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.
- 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.
- Copy the shared fields, override the per-transaction ones. Mutating the base in the loop passes every single-transaction test.
Decimalfrom the string, currency from theCcyattribute, sign fromCdtDbtInd. ISO 20022 amounts are always positive.- Select balances by code (
OPBD,CLBD), never by position. defusedxmlplus a hard transaction bound. One stops entity attacks, the other stops a large valid file, and you need both.- Test with a batch file, because the sample your bank gives you has one transaction in it.