Bank statements
Parse camt.052, camt.053 and camt.054 statements in Rust: batch bookings, matching a collection run back to your file, returns and return fees.
The three camt messages describe the same thing — movements on an account — and differ mainly in timing. They share one entry model, so reconciliation code works across all of them.
| Message | Content | When |
|---|---|---|
camt.052 | Intraday report, provisional | During the day |
camt.053 | Account statement; inspect each reported entry status | Commonly overnight |
camt.054 | Notification of specific debits and credits | As events happen |
Accepted versions are camt.052/camt.053 001.02, 001.06 and 001.08, and camt.054 001.08. Resolved identity is not full XSD validation. See reader contracts for finite limits and evidence boundaries.
Reading a statement🔗
use sepa::parse_camt053;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let xml = std::fs::read_to_string("statement.xml")?;
let doc = parse_camt053(&xml)?;
let stmt = &doc.statements[0];
// `Acct/Id` is a choice — an IBAN or a proprietary identifier — so both
// are exposed and `any_id()` is the display shortcut.
println!("{:?} {:?}", stmt.account.any_id(), stmt.account.currency);
if let Some(ct) = stmt.closing_balance().and_then(|b| b.signed_ct()) {
println!("closing {ct} ct");
}
for entry in &stmt.entries {
match entry.signed_ct() {
Some(ct) => println!("{ct:+} ct {}", entry.reference().unwrap_or("")),
// The statement did not determine this one. Escalate the row —
// never substitute a figure.
None => println!("unresolved: amount {:?} direction {:?}",
entry.amount.amount_raw, entry.amount.direction_raw),
}
}
Ok(())
}Amounts are reported as magnitudes with a separate credit/debit indicator. signed_ct() returns signed hundredths only when magnitude, currency, direction and raw/typed observations are usable and consistent. Negative magnitudes, unrepresentable amounts and missing or unknown currency/direction return None. Hundredths are not an ISO currency minor-unit policy.
Supported entries retain decoded monetary text and observed currency/direction, including unreadable values. Protect payment data when escalating unresolved rows.
reference() is the remittance information — the Verwendungszweck. ISO makes RmtInf/Ustrd repeatable, and German banks routinely split a long reference into 35-character chunks, so every occurrence is joined with a single space. Reading only the first would cut the reference exactly where the invoice number tends to sit.
Batch bookings🔗
A direct debit run is normally booked as one aggregate entry with the individual transactions itemised underneath. Two things matter here.
First, the Btch block names the PmtInfId of the group that produced the booking — that is how a statement entry is matched back to the file you sent, without guessing from amounts and dates.
Second, the parts have to account for the whole before you post anything.
use sepa::parse_camt053;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let xml = std::fs::read_to_string("statement.xml")?;
for stmt in &parse_camt053(&xml)?.statements {
for entry in &stmt.entries {
if !entry.batch_booked {
continue;
}
if let Some(batch) = &entry.batch {
println!("from our group {:?}", batch.payment_info_id);
}
for detail in &entry.details {
// Resolved from the detail, from AmtDtls, or from the entry
// when there is only one transaction to attribute it to.
println!(" {:?} {:?}", detail.end_to_end_id, detail.signed_ct());
}
// Unresolved or inconsistent parts cannot be reconciled.
if !entry.details_reconcile() {
eprintln!("batch does not reconcile — do not post");
}
}
}
Ok(())
}
signed_ct()on a detail returnsOption.Nonemeans the statement does not determine that transaction’s amount — which is exactly the case where the tempting workaround, reusing the entry total for each detail, multiplies a batch booking by its transaction count.
Returns🔗
A returned collection is where camt.054 earns its place:
use sepa::parse_camt054;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let xml = std::fs::read_to_string("notification.xml")?;
for notification in &parse_camt054(&xml)?.notifications {
for returned in notification.returns() {
let detail = returned.first_detail();
println!(
"{:?}: {:?}",
returned.return_reason_code(),
detail.and_then(|d| d.return_additional_info.as_deref()),
);
}
}
Ok(())
}is_return() on an entry checks every detail, so a single returned collection inside an otherwise-fine batch is still reported.
The return fee🔗
A return costs the creditor money, and the fee is reported in Chrgs rather than inside the entry amount. It appears on the entry or on the transaction detail depending on the bank, so read both:
use sepa::parse_camt053;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.08">
<BkToCstmrStmt><GrpHdr><MsgId>M</MsgId></GrpHdr><Stmt><Id>S</Id>
<Ntry><Amt Ccy="EUR">75.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<Chrgs><Rcrd><Amt Ccy="EUR">3.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<ChrgInclInd>false</ChrgInclInd></Rcrd></Chrgs>
</Ntry>
</Stmt></BkToCstmrStmt>
</Document>"#;
let entry = &parse_camt053(xml)?.statements[0].entries[0];
if let Some(charges) = &entry.charges {
// Negative: a fee is money out.
assert_eq!(charges.total_signed_ct(), Some(-300));
// Stated as *not* already inside the entry amount, so a ledger posts it
// in addition to the 75.00.
assert!(!charges.all_included_in_amount());
}
Ok(())
}ChrgInclInd is optional, and “the bank did not say” is a third answer rather than a default. included_in_amount is therefore an Option<bool>, and all_included_in_amount() is false unless every record says so explicitly. False does not authorize posting: inspect each record’s inclusion; None is unresolved. Missing direction is not assumed debit. Invalid aggregate totals survive as raw text; absent records, mixed currencies and conflicting totals cannot produce a trusted sum.
ISO reshaped this block across versions — up to .001.02 the charge sits directly under Chrgs, and from .001.04 it moved into Rcrd blocks. Both are read, and the flat form is reported as a single record so there is one shape to handle.
Dates and raw values🔗
ISO types a booking date as a date or a date-time, so the same field arrives as 2026-07-20 from one bank and 2026-07-20T09:14:00 from the next. Both are handled, and the original text is kept alongside the parsed value:
use sepa::parse_camt053;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let xml = std::fs::read_to_string("statement.xml")?;
for stmt in &parse_camt053(&xml)?.statements {
for entry in &stmt.entries {
println!("{:?} (as sent: {:?})", entry.booking_date(), entry.booking_date_raw);
}
}
Ok(())
}Supported date observations remain available when typed conversion fails. XML, identity and limit violations still reject the input.
Free text🔗
AddtlNtryInf carries the bank’s own statement text, and for an entry with no itemised detail it is often the only description in the file. It is exposed on the entry, alongside AddtlTxInf per detail.
Security🔗
Parsing uses quick-xml, so comments, entity references, CDATA and self-closing tags are all handled correctly, and a prefixed <ns2:Document> parses identically to a default-namespaced one. <!DOCTYPE> is rejected outright, nesting is capped, and a second root element is an error rather than silently replacing the first.
Full API reference on docs.rs. Found a mistake? Open an issue.