use crate::camt::{self, AccountRef, 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: AccountRef,
pub from_date_raw: Option<String>,
pub to_date_raw: Option<String>,
pub balances: Vec<StatementBalance>,
pub entries: Vec<CashEntry>,
}
impl Camt052Report {
#[must_use]
pub fn from_date(&self) -> Option<crate::IsoDate> {
crate::IsoDate::parse_date_part(self.from_date_raw.as_deref()?).ok()
}
#[must_use]
pub fn to_date(&self) -> Option<crate::IsoDate> {
crate::IsoDate::parse_date_part(self.to_date_raw.as_deref()?).ok()
}
#[must_use]
pub fn net_movement_ct(&self) -> Option<i64> {
self.entries
.iter()
.try_fold(0i64, |acc, e| Some(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_raw: String,
pub namespace: Option<String>,
pub reports: Vec<Camt052Report>,
}
impl Camt052Document {
#[must_use]
pub fn created_at(&self) -> Option<crate::IsoDateTime> {
crate::IsoDateTime::parse(&self.created_at_raw).ok()
}
}
#[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_raw: 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: camt::account_of(r),
from_date_raw: from_date,
to_date_raw: 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>
<FrToDt><FrDtTm>2026-07-14T00:00:00</FrDtTm><ToDtTm>2026-07-14T11:00:00</ToDtTm></FrToDt>
<Acct>
<Id><IBAN>DE89370400440532013000</IBAN></Id>
<Svcr><FinInstnId><BICFI>COBADEFFXXX</BICFI></FinInstnId></Svcr>
</Acct>
<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>
<BkTxCd><Domn><Cd>PMNT</Cd><Fmly><Cd>RDDT</Cd><SubFmlyCd>PMDD</SubFmlyCd></Fmly></Domn></BkTxCd>
<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>
<BkTxCd><Domn><Cd>PMNT</Cd><Fmly><Cd>RDDT</Cd><SubFmlyCd>PMDD</SubFmlyCd></Fmly></Domn></BkTxCd>
</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.as_deref(), Some("DE89370400440532013000"));
assert_eq!(rpt.account.servicer_bic.as_deref(), Some("COBADEFFXXX"));
assert_eq!(rpt.from_date_raw.as_deref(), Some("2026-07-14T00:00:00"));
assert_eq!(rpt.to_date_raw.as_deref(), Some("2026-07-14T11:00:00"));
assert_eq!(
rpt.from_date(),
Some(crate::IsoDate::new(2026, 7, 14).unwrap())
);
assert_eq!(
rpt.to_date(),
Some(crate::IsoDate::new(2026, 7, 14).unwrap())
);
}
#[test]
fn intraday_balance_and_entries() {
let doc = parse_camt052(REPORT).unwrap();
let rpt = &doc.reports[0];
assert_eq!(rpt.balances[0].amount.ct, Some(100_000));
assert_eq!(rpt.balances[0].amount.currency, Some("EUR".to_owned()));
assert_eq!(rpt.entries.len(), 2);
assert_eq!(rpt.entries[0].signed_ct(), Some(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].amount.direction,
Some(CreditDebitIndicator::Debit)
);
assert_eq!(rpt.net_movement_ct(), Some(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, Some(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);
}
}