← Blog
How it works

A bank statement can prove it is complete

“Our bank will send camt.053 instead of CSV and I was told that would fix our import problems. The transactions do come in, but the totals still do not match what the bank says the account closed at, and nothing in the tool tells me which side is wrong.”

Here are two lines from the schema for a bank statement. They are the declarations for the balances and for the transactions, inside the type that describes one statement:

<xs:element maxOccurs="unbounded" minOccurs="1" name="Bal" type="CashBalance8"/>
<xs:element maxOccurs="unbounded" minOccurs="0" name="Ntry" type="ReportEntry10"/>

Bal is a balance. Ntry is an entry, which is the thing you would call a transaction. The balances have minOccurs="1", so a statement must carry at least one. The entries have minOccurs="0", so a statement may carry none at all.

That is the opposite of how anyone describes a bank statement. The list of what happened is the optional part. The statement of where the account stood is the part you are not allowed to leave out.

I checked that against every published version of the message, from camt.053.001.01 to camt.053.001.13. The two cardinalities are the same in all thirteen. Whatever else has changed across the versions, and plenty has, the design has never wavered on this one point: a statement is required to say what it proves, and permitted to say nothing about what it contains.

Everything below was measured today from ISO's own schema files, from ISO's published definitions of the codes they use, and from the source of an importer that reads them. What I could not check is listed at the end.

What the format says that a CSV cannot

The earlier post on this blog about bank statement CSVs pointed at structured formats like this one as the way out, said that such a format "carries its own meaning", and said that we had not tested the ISO 20022 route ourselves. This is that test, and the result is not the one I expected to write.

Start with the amount. In the schema an entry's amount has this type:

<xs:simpleType name="ActiveOrHistoricCurrencyAndAmount_SimpleType">
    <xs:restriction base="xs:decimal">
        <xs:fractionDigits value="5"/>
        <xs:totalDigits value="18"/>
        <xs:minInclusive value="0"/>
    </xs:restriction>
</xs:simpleType>

minInclusive value="0". A negative amount is not merely discouraged, it is invalid against the schema. The direction lives in its own mandatory field:

<xs:simpleType name="CreditDebitCode">
    <xs:restriction base="xs:string">
        <xs:enumeration value="CRDT"/>
        <xs:enumeration value="DBIT"/>
    </xs:restriction>
</xs:simpleType>

So a reader cannot get the sign wrong by guessing a convention, because there is no convention to guess. It reads a magnitude and a direction and combines them. A CSV column of amounts, by contrast, is a column of strings whose sign convention is whatever the writer felt like, and whose currency is nowhere.

Three more fields on the entry carry things a CSV has no way to say. All of them are quoted here from the same schema file:

<xs:element maxOccurs="1" minOccurs="0" name="RvslInd" type="TrueFalseIndicator"/>
<xs:element name="Sts" type="EntryStatus1Choice"/>
<xs:element maxOccurs="unbounded" minOccurs="0" name="NtryDtls" type="EntryDetails9"/>

Sts is the entry's status, and it has no minOccurs, which in this schema means it is mandatory. Every entry in a conformant file states whether it is booked or still pending, and it is mandatory in all thirteen versions. In the very first one it was an enumeration of a single value, BOOK, so the field existed before there was anything to say with it: version 01 could only describe entries that had already been booked, and the ability to tell you about a pending one arrived with version 02. RvslInd is a boolean saying the entry is a reversal. NtryDtls is repeatable, and the type it points at holds a repeatable TxDtls, so one entry can carry many underlying transactions: a batch of direct debits arrives as one line on the account with its parts attached.

And then the balances, the mandatory part. Each one carries a type code, and in the older versions that code list is written out in the schema itself:

<xs:simpleType name="BalanceType12Code">
    <xs:restriction base="xs:string">
        <xs:enumeration value="XPCD"/>
        <xs:enumeration value="OPAV"/>
        <xs:enumeration value="ITAV"/>
        <xs:enumeration value="CLAV"/>
        <xs:enumeration value="FWAV"/>
        <xs:enumeration value="CLBD"/>
        <xs:enumeration value="ITBD"/>
        <xs:enumeration value="OPBD"/>
        <xs:enumeration value="PRCD"/>
        <xs:enumeration value="INFO"/>
    </xs:restriction>
</xs:simpleType>

Two of those are the ones that matter here, OPBD and CLBD, and ISO's published code set defines them in exactly the terms this post needs. OPBD is the "Book balance of the account at the beginning of the account reporting period". CLBD is the balance at the end of the period, and the definition goes on:

It is the sum of the opening booked balance at the beginning of the period and all entries booked to the account during the pre-agreed account reporting period.

So the standard does not merely make the check possible. It states it. A statement that carries a balance before the period and a balance after it has stated the change over the period as a number, independently of the lines that caused it. Add up the booked entries. Compare. If the two do not agree, something was dropped, duplicated, or misread, and you know that before you have shown the user anything.

That is the whole of the idea. The file comes with its own check.

The check, on a file built to be checked

I wrote a statement in camt.053.001.08 and validated it against the schema, which it passes. It opens at 1000.00 EUR and closes at 1050.00 EUR, and it has five entries:

entry 1: Amt=200.00 CRDT Sts=BOOK RvslInd=(absent) TxDtls=1
entry 2: Amt=150.00 DBIT Sts=BOOK RvslInd=(absent) TxDtls=1
entry 3: Amt=500.00 DBIT Sts=PDNG RvslInd=(absent) TxDtls=1
entry 4: Amt=100.00 CRDT Sts=BOOK RvslInd=true TxDtls=1
entry 5: Amt=100.00 DBIT Sts=BOOK RvslInd=(absent) TxDtls=2

The four booked entries come to plus 50.00, which is exactly the declared change from 1000.00 to 1050.00. The file is internally consistent and says so twice.

The third row of that table is the one that is not booked. It is a card authorisation of 500.00 that the bank has not applied to the account, and it is in the file because camt.053 is willing to tell you about it. ISO's definition of PDNG is blunt: "Booking on the account owner's account in the account servicer's ledger has not been completed." A reader that treats every entry as a transaction gets minus 450.00 instead of plus 50.00, closes the account at 550.00, and is wrong by 500.00. The two numbers that would have caught it sit in the same file immediately above the entries, and they are the ones the schema refuses to let the bank omit.

The importer

Actual Budget is an open source budgeting application that imports camt.053. Its reader is one file of 193 lines. It begins the work like this:

  const entries = findKeys(data, 'Ntry') as Ntry[];

findKeys is defined a few lines above and walks the entire parsed document recursively, collecting everything under any key of that name. It is an effective way to find the entries and it has a side effect: the statement structure is gone. There is no longer a statement, or an account the statement belongs to, or a period, or a balance. There is a flat list of entries lifted out of a document whose remaining contents were never looked at.

What happens next is the part I want to quote in full, because the code says it better than I can. This is the top of the loop over those entries:

  for (const entry of entries) {
    /*
      For (camt.052/054) could filter on entry.Sts= BOOK or PDNG, currently importing all entries
    */

The field is known. The values are known. The behaviour is accurate about itself: currently importing all entries.

I looked for anywhere else in that package that reads a status, a reversal flag or a balance. Across every TypeScript file in that package, RvslInd, CLBD and OPBD appear zero times, Bal as a whole word appears zero times, and Sts as a whole word appears once. The once is that comment. Not one of those five is read anywhere.

The comment is not a recent note-to-self. It was written in the commit that added camt.053 support, on 2024-05-09, which is 902 days before today. Two months after that a commit landed with the subject "CAMT.053: Handle missing ValDt on entries with Sts Book". It names the status field in its own title. Its change to the parser is three lines, and they are a null check on the date:

 function getDtOrDtTm(Date: DateRef | null): string | null {
+  if (!Date) {
+    return null;
+  }
   if ('DtTm' in Date) {

Nobody did anything wrong in that commit. It fixed the crash it set out to fix. It is just that the status field had by then become a thing you mention in a commit subject rather than a thing you read.

What skipping the status costs

A row that nothing marks as pending does not arrive neutral. It arrives confirmed.

The importer does not set a cleared flag, so the write path downstream supplies one:

      cleared: trans.cleared != null ? trans.cleared : true,

A PDNG entry therefore lands in the ledger as a cleared transaction, which is the application's way of recording that the bank has confirmed it. The one piece of information the file was carrying about that entry is replaced by its opposite. That last step is an inference from two pieces of source, the reader that sets no flag and the writer that defaults it to true, rather than something I watched happen; the fixture below is all booked, so it does not exercise the case.

I did not run the importer. I did not have to, because the project records what it produces. Its test suite imports a camt.053 fixture and compares the result against a committed snapshot, and that snapshot is in the repository. Every row in it reads "cleared": 1 and "pending": 0.

The same snapshot gives the second finding, and this one is about the fixture. The fixture carries two balances dated the same day, one typed PRCD at 24.16 EUR and one typed CLBD at 27.61 EUR. PRCD is the balance at the previously closed period, and ISO's definition adds that "The opening booked balance for the new period has to be equal to this balance", so the file declares a change of plus 3.45. Its six entries sum to minus 164.10. The committed snapshot records seven rows totalling the same minus 164.10, so the parser and I agree about the file; we disagree with the file's own balances by 167.55.

I am not holding that up as a bank's output, because it is a hand assembled test file and one of its entries is 3.45 on the nose, which is what assembling looks like. The point is narrower and it is about tooling rather than about anyone's care: this file has been in the test suite for over two years, it is the project's reference example of a statement, its own declared balances contradict its own entries by 167.55 on a declared change of 3.45, and nothing in the pipeline is in a position to notice. A balance check is four lines of arithmetic and it would have failed on the first run.

Two smaller things fall out of the same snapshot. The fixture has one entry carrying two TxDtls, and the parser splits it into two ledger rows, which is reasonable. Both rows come out with "financial_id": null, because the bank's reference lives on the entry and the split rows are given none, so nothing can recognise them again later. And the parser reaches for a sub-transaction amount with findKeys(TxDtls, 'Amt') and takes the first result, which is the first Amt anywhere beneath that element in document order. When the transaction carries its own amount, that comes first and is the one found, as it is in this fixture. But the schema makes the transaction's own amount optional, and amounts also appear deeper down, under exchange and charge details, so for a file that leaves it out the parser takes whichever of those it meets first.

The schema got better and less checkable at the same time

One more thing changed across the versions, and it cuts the other way.

In the older versions the status is an enumerated type, so a validator knows the permitted values:

<xs:simpleType name="EntryStatus2Code">
    <xs:restriction base="xs:string">
        <xs:enumeration value="BOOK"/>
        <xs:enumeration value="PDNG"/>
        <xs:enumeration value="INFO"/>
    </xs:restriction>
</xs:simpleType>

From version 07 onward the status became a choice between a code and a proprietary string, and the code half is this:

<xs:simpleType name="ExternalEntryStatus1Code">
    <xs:restriction base="xs:string">
        <xs:minLength value="1"/>
        <xs:maxLength value="4"/>
    </xs:restriction>
</xs:simpleType>

Any one to four characters. The balance type codes moved out the same way. The lists still exist, they are just maintained outside the schema now, which is a sensible thing to do with a list that changes faster than a schema version. The consequence for a reader is concrete: schema validation used to tell you that a status was one of three things, and now it tells you that it is short. If you want to know that BOOK means booked, the schema is no longer where you find out (that is now a separate code set that ISO publishes alongside it), and an importer that validates and then trusts has less under it than it did.

The part worth taking away

A camt.053 file is not just a better formatted CSV. It carries three things a CSV structurally cannot: the direction of each amount as its own field, the status of each entry, and a declared opening and closing position that the entries are supposed to reconcile to. The first of those is hard to ignore, because you need it to get the sign right. The second and third are easy to ignore, because you can produce a plausible looking list of transactions without them.

So the format moved the hard part from the reader to the writer, and then the readers left the proof on the floor. If you are writing one of these importers, the balance check is the cheapest test you will ever add: sum the booked entries, compare against the closing balance minus the opening balance, and refuse the file if they disagree. The file is telling you whether you read it correctly.

What was verified, and what was not

The schema quotations are from ISO's own XSD files for camt.053.001.01 through camt.053.001.13, downloaded from iso20022.org today (for example camt.053.001.08). Every claim about cardinalities, types and code lists was checked against all thirteen, not inferred from two.

  • The code definitions are ISO's published external code sets, read from copies. The quoted meanings of OPBD, CLBD, PRCD and PDNG come from ISO's external code set file in its JSON form. I could not fetch that file from ISO's own catalogue, so I read it from two copies, the 2023 and the 2024 editions, carried by two unrelated open source projects. The definitions quoted here are word for word the same in both.
  • The message definition report was not read. That is where ISO describes each field in prose, so this post says that a reversal flag exists and is ignored, and does not say what a reversal does to the sign of an amount.
  • No national implementation guide was read. Banks differ in which balance types they send and how they fill in the status, and this post says nothing about that.
  • The importer was read, not run. The behaviour attributed to Actual Budget's parser on my own test file is a reading of 193 lines of source, at the project's default branch as of 2026-10-27, not an observed run. The behaviour on its file is different: that comes from the project's own committed test snapshot, which is a record it produced itself.
  • My test file is valid camt.053. It validates against ISO's camt.053.001.08 schema. Its balances, amounts, statuses and entry counts were read out of the XML by two independent parsers, Python's standard library and libxml2, and they agree. The arithmetic was done in exact decimal, not floating point.
  • No count of any kind appears in this post: not of reports, not of affected projects, not of users. I had no way to check one.

This post names no product of ours, because none of them reads this format. apps is empty on purpose.