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,PRCDandPDNGcome 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.08schema. 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.