use crate::camt::{self, CashEntry, StatementBalance};
use crate::xml::{Document, Node, XmlError};
pub mod ns {
pub const CAMT052_001_02: &str = "urn:iso:std:iso:20022:tech:xsd:camt.052.001.02";
pub const CAMT052_001_06: &str = "urn:iso:std:iso:20022:tech:xsd:camt.052.001.06";
pub const CAMT052_001_08: &str = "urn:iso:std:iso:20022:tech:xsd:camt.052.001.08";
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Camt052Report {
pub report_id: String,
pub sequence_number: Option<u64>,
pub account_iban: String,
pub account_servicer_bic: Option<String>,
pub from_date: Option<String>,
pub to_date: Option<String>,
pub balances: Vec<StatementBalance>,
pub entries: Vec<CashEntry>,
}
impl Camt052Report {
#[must_use]
pub fn net_movement_ct(&self) -> i64 {
self.entries
.iter()
.fold(0i64, |acc, e| acc.saturating_add(e.signed_ct()))
}
pub fn pending_entries(&self) -> impl Iterator<Item = &CashEntry> {
self.entries
.iter()
.filter(|e| e.status == crate::EntryStatus::Pending)
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Camt052Document {
pub msg_id: String,
pub created_at: String,
pub namespace: Option<String>,
pub reports: Vec<Camt052Report>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum Camt052ParseError {
#[error(transparent)]
Xml(#[from] XmlError),
#[error("not a camt.052 document: root element <BkToCstmrAcctRpt> not found")]
NotCamt052,
}
pub fn parse_camt052(xml: &str) -> Result<Camt052Document, Camt052ParseError> {
let doc = Document::parse(xml)?;
let root = doc
.root
.child("BkToCstmrAcctRpt")
.ok_or(Camt052ParseError::NotCamt052)?;
let grp_hdr = root.child("GrpHdr");
let text = |tag: &str| {
grp_hdr
.and_then(|h| h.text_of(tag))
.unwrap_or_default()
.to_owned()
};
Ok(Camt052Document {
msg_id: text("MsgId"),
created_at: text("CreDtTm"),
namespace: doc.namespace,
reports: root.children_named("Rpt").map(parse_report).collect(),
})
}
fn parse_report(r: &Node) -> Camt052Report {
let (from_date, to_date) = camt::period(r);
Camt052Report {
report_id: r.text_of("Id").unwrap_or_default().to_owned(),
sequence_number: r.text_of("ElctrncSeqNb").and_then(|v| v.parse().ok()),
account_iban: camt::account_iban(r),
account_servicer_bic: r
.path(&["Acct", "Svcr"])
.and_then(camt::agent_bic)
.map(str::to_owned),
from_date,
to_date,
balances: camt::balances_of(r),
entries: camt::entries_of(r),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::EntryStatus;
use crate::camt054::CreditDebitIndicator;
const REPORT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.052.001.08">
<BkToCstmrAcctRpt>
<GrpHdr><MsgId>RPT-2026-07-14</MsgId><CreDtTm>2026-07-14T11:00:00</CreDtTm></GrpHdr>
<Rpt>
<Id>INTRADAY-1</Id>
<ElctrncSeqNb>7</ElctrncSeqNb>
<Acct>
<Id><IBAN>DE89370400440532013000</IBAN></Id>
<Svcr><FinInstnId><BICFI>COBADEFFXXX</BICFI></FinInstnId></Svcr>
</Acct>
<FrToDt><FrDtTm>2026-07-14T00:00:00</FrDtTm><ToDtTm>2026-07-14T11:00:00</ToDtTm></FrToDt>
<Bal>
<Tp><CdOrPrtry><Cd>ITBD</Cd></CdOrPrtry></Tp>
<Amt Ccy="EUR">1000.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Dt><Dt>2026-07-14</Dt></Dt>
</Bal>
<Ntry>
<Amt Ccy="EUR">250.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Sts><Cd>PDNG</Cd></Sts>
<NtryDtls><TxDtls>
<Refs><EndToEndId>E2E-PENDING</EndToEndId></Refs>
<RltdPties><Dbtr><Pty><Nm>Zahler GmbH</Nm></Pty></Dbtr></RltdPties>
</TxDtls></NtryDtls>
</Ntry>
<Ntry>
<Amt Ccy="EUR">75.00</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<Sts><Cd>BOOK</Cd></Sts>
</Ntry>
</Rpt>
</BkToCstmrAcctRpt>
</Document>"#;
#[test]
fn parses_report_header_and_account() {
let doc = parse_camt052(REPORT).unwrap();
assert_eq!(doc.msg_id, "RPT-2026-07-14");
assert_eq!(
doc.namespace.as_deref(),
Some("urn:iso:std:iso:20022:tech:xsd:camt.052.001.08")
);
let rpt = &doc.reports[0];
assert_eq!(rpt.report_id, "INTRADAY-1");
assert_eq!(rpt.sequence_number, Some(7));
assert_eq!(rpt.account_iban, "DE89370400440532013000");
assert_eq!(rpt.account_servicer_bic.as_deref(), Some("COBADEFFXXX"));
assert_eq!(rpt.from_date.as_deref(), Some("2026-07-14T00:00:00"));
assert_eq!(rpt.to_date.as_deref(), Some("2026-07-14T11:00:00"));
}
#[test]
fn intraday_balance_and_entries() {
let doc = parse_camt052(REPORT).unwrap();
let rpt = &doc.reports[0];
assert_eq!(rpt.balances[0].amount_ct, 100_000);
assert_eq!(rpt.balances[0].currency, "EUR");
assert_eq!(rpt.entries.len(), 2);
assert_eq!(rpt.entries[0].signed_ct(), 25_000);
assert_eq!(rpt.entries[0].status, EntryStatus::Pending);
assert_eq!(rpt.entries[0].end_to_end_id(), Some("E2E-PENDING"));
assert_eq!(rpt.entries[0].counterparty_name(), Some("Zahler GmbH"));
assert_eq!(rpt.entries[1].indicator, CreditDebitIndicator::Debit);
assert_eq!(rpt.net_movement_ct(), 17_500);
}
#[test]
fn pending_entries_are_separable_from_booked() {
let doc = parse_camt052(REPORT).unwrap();
let pending: Vec<_> = doc.reports[0].pending_entries().collect();
assert_eq!(pending.len(), 1, "only the PDNG entry is provisional");
assert_eq!(pending[0].amount_ct, 25_000);
}
#[test]
fn rejects_other_message_types() {
let stmt = r#"<Document xmlns="urn:x"><BkToCstmrStmt/></Document>"#;
assert_eq!(
parse_camt052(stmt).unwrap_err(),
Camt052ParseError::NotCamt052
);
}
#[test]
fn legacy_bare_status_code_is_accepted() {
let xml = REPORT
.replace("<Sts><Cd>PDNG</Cd></Sts>", "<Sts>PDNG</Sts>")
.replace("camt.052.001.08", "camt.052.001.02");
let doc = parse_camt052(&xml).unwrap();
assert_eq!(doc.reports[0].entries[0].status, EntryStatus::Pending);
}
}