use crate::camt054::CreditDebitIndicator;
use crate::xml::Node;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BalanceType {
OpeningBooked,
ClosingBooked,
IntradayBooked,
ClosingAvailable,
OpeningAvailable,
ForwardAvailable,
Other(String),
}
impl BalanceType {
#[must_use]
pub fn as_code(&self) -> &str {
match self {
Self::OpeningBooked => "OPBD",
Self::ClosingBooked => "CLBD",
Self::IntradayBooked => "ITBD",
Self::ClosingAvailable => "CLAV",
Self::OpeningAvailable => "OPAV",
Self::ForwardAvailable => "FWAV",
Self::Other(s) => s,
}
}
pub(crate) fn from_code(s: &str) -> Self {
match s.trim().to_ascii_uppercase().as_str() {
"OPBD" => Self::OpeningBooked,
"CLBD" => Self::ClosingBooked,
"ITBD" => Self::IntradayBooked,
"CLAV" => Self::ClosingAvailable,
"OPAV" => Self::OpeningAvailable,
"FWAV" => Self::ForwardAvailable,
other => Self::Other(other.to_owned()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum EntryStatus {
Booked,
Pending,
Info,
Future,
Other(String),
}
impl EntryStatus {
pub(crate) fn from_code(s: &str) -> Self {
match s.trim().to_ascii_uppercase().as_str() {
"BOOK" => Self::Booked,
"PDNG" => Self::Pending,
"INFO" => Self::Info,
"FUTR" => Self::Future,
other => Self::Other(other.to_owned()),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StatementBalance {
pub balance_type: BalanceType,
pub amount_ct: i64,
pub currency: String,
pub indicator: CreditDebitIndicator,
pub date_raw: String,
}
impl StatementBalance {
#[inline]
#[must_use]
pub fn signed_ct(&self) -> i64 {
signed(self.indicator, self.amount_ct)
}
#[must_use]
pub fn date(&self) -> Option<crate::IsoDate> {
crate::IsoDate::parse_date_part(&self.date_raw).ok()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EntryDetail {
pub amount_ct: Option<i64>,
pub currency: Option<String>,
pub indicator: CreditDebitIndicator,
pub signed_amount_ct: Option<i64>,
pub end_to_end_id: Option<String>,
pub mandate_id: Option<String>,
pub creditor_id: Option<String>,
pub reference: Option<String>,
pub counterparty_name: Option<String>,
pub counterparty_iban: Option<String>,
pub return_reason_code: Option<String>,
pub return_additional_info: Option<String>,
pub additional_info: Option<String>,
pub charges: Option<Charges>,
}
impl EntryDetail {
#[inline]
#[must_use]
pub const fn signed_ct(&self) -> Option<i64> {
self.signed_amount_ct
}
#[inline]
#[must_use]
pub const fn is_return(&self) -> bool {
self.return_reason_code.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ChargeRecord {
pub amount_ct: i64,
pub currency: String,
pub indicator: CreditDebitIndicator,
pub included_in_amount: Option<bool>,
pub type_code: Option<String>,
}
impl ChargeRecord {
#[inline]
#[must_use]
pub fn signed_ct(&self) -> i64 {
signed(self.indicator, self.amount_ct)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Charges {
pub total_ct: Option<i64>,
pub total_currency: Option<String>,
pub records: Vec<ChargeRecord>,
}
impl Charges {
#[must_use]
pub fn total_signed_ct(&self) -> Option<i64> {
self.records
.iter()
.try_fold(0i64, |acc, r| acc.checked_add(r.signed_ct()))
}
#[must_use]
pub fn all_included_in_amount(&self) -> bool {
!self.records.is_empty()
&& self
.records
.iter()
.all(|r| r.included_in_amount == Some(true))
}
fn parse(node: &Node) -> Option<Self> {
let chrgs = node.child("Chrgs")?;
let record_of = |n: &Node| {
let (amount_ct, currency) = amount_of(n, "Amt")?;
Some(ChargeRecord {
amount_ct,
currency,
indicator: n
.text_of("CdtDbtInd")
.and_then(|s| s.parse().ok())
.unwrap_or(CreditDebitIndicator::Debit),
included_in_amount: n.text_of("ChrgInclInd").and_then(|v| match v.trim() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
}),
type_code: n.child("Tp").and_then(Node::code).map(str::to_owned),
})
};
let mut records: Vec<ChargeRecord> =
chrgs.children_named("Rcrd").filter_map(record_of).collect();
if records.is_empty()
&& let Some(flat) = record_of(chrgs)
{
records.push(flat);
}
let total = amount_of(chrgs, "TtlChrgsAndTaxAmt");
if records.is_empty() && total.is_none() {
return None;
}
Some(Self {
total_ct: total.as_ref().map(|(ct, _)| *ct),
total_currency: total.map(|(_, ccy)| ccy),
records,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AccountRef {
pub iban: Option<String>,
pub other_id: Option<String>,
pub currency: Option<String>,
pub servicer_bic: Option<String>,
}
impl AccountRef {
#[must_use]
pub fn any_id(&self) -> Option<&str> {
self.iban.as_deref().or(self.other_id.as_deref())
}
fn parse(node: &Node) -> Self {
let Some(acct) = node.child("Acct") else {
return Self::default();
};
let id = acct.child("Id");
Self {
iban: id.and_then(|i| i.text_of("IBAN")).map(str::to_owned),
other_id: id
.and_then(|i| i.text_at(&["Othr", "Id"]))
.map(str::to_owned),
currency: acct.text_of("Ccy").map(str::to_owned),
servicer_bic: acct.child("Svcr").and_then(agent_bic).map(str::to_owned),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BatchInfo {
pub message_id: Option<String>,
pub payment_info_id: Option<String>,
pub transaction_count: Option<u64>,
}
impl BatchInfo {
fn parse(node: &Node) -> Self {
Self {
message_id: node.text_of("MsgId").map(str::to_owned),
payment_info_id: node.text_of("PmtInfId").map(str::to_owned),
transaction_count: node.text_of("NbOfTxs").and_then(|n| n.parse().ok()),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CashEntry {
pub amount_ct: i64,
pub currency: String,
pub indicator: CreditDebitIndicator,
pub status: EntryStatus,
pub batch_booked: bool,
pub batch: Option<BatchInfo>,
pub booking_date_raw: Option<String>,
pub value_date_raw: Option<String>,
pub account_servicer_ref: Option<String>,
pub bank_tx_code: Option<String>,
pub additional_info: Option<String>,
pub charges: Option<Charges>,
pub details: Vec<EntryDetail>,
}
impl CashEntry {
#[inline]
#[must_use]
pub fn signed_ct(&self) -> i64 {
signed(self.indicator, self.amount_ct)
}
#[must_use]
pub fn booking_date(&self) -> Option<crate::IsoDate> {
parse_date(self.booking_date_raw.as_deref())
}
#[must_use]
pub fn value_date(&self) -> Option<crate::IsoDate> {
parse_date(self.value_date_raw.as_deref())
}
#[inline]
#[must_use]
pub fn first_detail(&self) -> Option<&EntryDetail> {
self.details.first()
}
#[must_use]
pub fn end_to_end_id(&self) -> Option<&str> {
self.first_detail()?.end_to_end_id.as_deref()
}
#[must_use]
pub fn mandate_id(&self) -> Option<&str> {
self.first_detail()?.mandate_id.as_deref()
}
#[must_use]
pub fn creditor_id(&self) -> Option<&str> {
self.first_detail()?.creditor_id.as_deref()
}
#[must_use]
pub fn reference(&self) -> Option<&str> {
self.first_detail()?.reference.as_deref()
}
#[must_use]
pub fn counterparty_name(&self) -> Option<&str> {
self.first_detail()?.counterparty_name.as_deref()
}
#[must_use]
pub fn counterparty_iban(&self) -> Option<&str> {
self.first_detail()?.counterparty_iban.as_deref()
}
#[must_use]
pub fn return_reason_code(&self) -> Option<&str> {
self.first_detail()?.return_reason_code.as_deref()
}
#[must_use]
pub fn is_return(&self) -> bool {
self.details.iter().any(EntryDetail::is_return)
}
#[must_use]
pub fn details_signed_sum_ct(&self) -> Option<i64> {
self.details
.iter()
.try_fold(0i64, |acc, d| acc.checked_add(d.signed_ct()?))
}
#[must_use]
pub fn details_reconcile(&self) -> bool {
if self.details.is_empty() {
return true;
}
self.details_signed_sum_ct() == Some(self.signed_ct())
}
}
pub(crate) fn amount_of(node: &Node, tag: &str) -> Option<(i64, String)> {
let amt = node.child(tag)?;
let ct = crate::ct_from_eur_str(&amt.text).ok()?.checked_abs()?;
Some((ct, amt.attr("Ccy").unwrap_or("EUR").to_owned()))
}
pub(crate) fn indicator_of(node: &Node) -> CreditDebitIndicator {
node.text_of("CdtDbtInd")
.and_then(|s| s.parse().ok())
.unwrap_or(CreditDebitIndicator::Credit)
}
fn parse_date(raw: Option<&str>) -> Option<crate::IsoDate> {
crate::IsoDate::parse_date_part(raw?).ok()
}
const fn signed(indicator: CreditDebitIndicator, amount_ct: i64) -> i64 {
match indicator {
CreditDebitIndicator::Credit => amount_ct,
CreditDebitIndicator::Debit => amount_ct.saturating_neg(),
}
}
fn joined_unstructured(rmt_inf: &Node) -> Option<String> {
let mut out = String::new();
for part in rmt_inf.children_named("Ustrd") {
if part.text.is_empty() {
continue;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(&part.text);
}
(!out.is_empty()).then_some(out)
}
pub(crate) fn party_name(parties: Option<&Node>, tag: &str) -> Option<String> {
let party = parties?.child(tag)?;
party
.text_of("Nm")
.or_else(|| party.text_at(&["Pty", "Nm"]))
.map(str::to_owned)
}
pub(crate) fn parse_balance(b: &Node) -> Option<StatementBalance> {
let balance_type = b
.path(&["Tp", "CdOrPrtry"])
.and_then(Node::code)
.map_or_else(|| BalanceType::Other(String::new()), BalanceType::from_code);
let (amount_ct, currency) = amount_of(b, "Amt")?;
let date = b
.child("Dt")
.and_then(|d| d.text_of("Dt").or_else(|| d.text_of("DtTm")))
.unwrap_or_default()
.to_owned();
Some(StatementBalance {
balance_type,
amount_ct,
currency,
indicator: indicator_of(b),
date_raw: date,
})
}
pub(crate) fn parse_entry(e: &Node) -> Option<CashEntry> {
let (amount_ct, currency) = amount_of(e, "Amt")?;
let indicator = indicator_of(e);
let status = e
.child("Sts")
.and_then(Node::code)
.map_or(EntryStatus::Booked, EntryStatus::from_code);
let date_of = |tag: &str| {
e.child(tag)
.and_then(|d| d.text_of("Dt").or_else(|| d.text_of("DtTm")))
.map(str::to_owned)
};
let bank_tx_code = e.child("BkTxCd").and_then(|c| {
c.text_at(&["Domn", "Cd"])
.or_else(|| c.text_at(&["Prtry", "Cd"]))
.map(str::to_owned)
});
let details_parent = e.child("NtryDtls").unwrap_or(e);
let detail_count = details_parent.children_named("TxDtls").count();
let batch = details_parent.child("Btch").map(BatchInfo::parse);
let batch_booked = batch.is_some() || detail_count > 1;
let sole_detail = detail_count == 1
&& batch
.as_ref()
.and_then(|b| b.transaction_count)
.is_none_or(|n| n == 1);
let details = details_parent
.children_named("TxDtls")
.map(|td| {
parse_detail(
td,
&EntryContext {
indicator,
amount_ct,
currency: ¤cy,
sole_detail,
},
)
})
.collect();
Some(CashEntry {
amount_ct,
currency,
indicator,
status,
batch_booked,
batch,
booking_date_raw: date_of("BookgDt"),
value_date_raw: date_of("ValDt"),
account_servicer_ref: e.text_of("AcctSvcrRef").map(str::to_owned),
bank_tx_code,
additional_info: e.text_of("AddtlNtryInf").map(str::to_owned),
charges: Charges::parse(e),
details,
})
}
pub(crate) struct EntryContext<'a> {
pub(crate) indicator: CreditDebitIndicator,
pub(crate) amount_ct: i64,
pub(crate) currency: &'a str,
pub(crate) sole_detail: bool,
}
pub(crate) fn parse_detail(td: &Node, entry: &EntryContext<'_>) -> EntryDetail {
let refs = td.child("Refs");
let ref_of = |tag: &str| refs.and_then(|r| r.text_of(tag)).map(str::to_owned);
let indicator = td
.text_of("CdtDbtInd")
.and_then(|s| s.parse().ok())
.unwrap_or(entry.indicator);
let parties = td.child("RltdPties");
let (name_tag, acct_tag) = match indicator {
CreditDebitIndicator::Credit => ("Dbtr", "DbtrAcct"),
CreditDebitIndicator::Debit => ("Cdtr", "CdtrAcct"),
};
let reported = amount_of(td, "Amt").or_else(|| {
td.child("AmtDtls")
.and_then(|ad| ad.child("TxAmt"))
.and_then(|ta| amount_of(ta, "Amt"))
});
let signed_amount_ct = match &reported {
Some((_, ccy)) if !ccy.eq_ignore_ascii_case(entry.currency) => None,
Some((ct, _)) => Some(signed(indicator, *ct)),
None if entry.sole_detail => Some(signed(indicator, entry.amount_ct)),
None => None,
};
EntryDetail {
amount_ct: reported.as_ref().map(|(ct, _)| *ct),
currency: reported.map(|(_, ccy)| ccy),
indicator,
signed_amount_ct,
end_to_end_id: ref_of("EndToEndId"),
mandate_id: ref_of("MndtId"),
creditor_id: ref_of("CdtrId"),
reference: td.child("RmtInf").and_then(joined_unstructured),
counterparty_name: party_name(parties, name_tag),
counterparty_iban: parties
.and_then(|p| p.text_at(&[acct_tag, "Id", "IBAN"]))
.map(str::to_owned),
return_reason_code: td
.path(&["RtrInf", "Rsn"])
.and_then(Node::code)
.map(str::to_owned),
return_additional_info: td.text_at(&["RtrInf", "AddtlInf"]).map(str::to_owned),
additional_info: td.text_of("AddtlTxInf").map(str::to_owned),
charges: Charges::parse(td),
}
}
pub(crate) fn account_of(node: &Node) -> AccountRef {
AccountRef::parse(node)
}
pub(crate) fn agent_bic(agent: &Node) -> Option<&str> {
let fin = agent.child("FinInstnId")?;
fin.text_of("BIC").or_else(|| fin.text_of("BICFI"))
}
pub(crate) fn period(node: &Node) -> (Option<String>, Option<String>) {
let range = node.child("FrToDt");
let at = |dt_tm: &str, dt: &str| {
range
.and_then(|r| r.text_of(dt_tm).or_else(|| r.text_of(dt)))
.map(str::to_owned)
};
(at("FrDtTm", "FrDt"), at("ToDtTm", "ToDt"))
}
pub(crate) fn entries_of(node: &Node) -> Vec<CashEntry> {
node.children_named("Ntry")
.filter_map(parse_entry)
.collect()
}
pub(crate) fn balances_of(node: &Node) -> Vec<StatementBalance> {
node.children_named("Bal")
.filter_map(parse_balance)
.collect()
}
#[cfg(test)]
mod tests {
use super::{CashEntry, CreditDebitIndicator};
fn entry(entry_xml: &str) -> CashEntry {
let xml = format!(
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>
{entry_xml}
</Stmt></BkToCstmrStmt>
</Document>"#
);
crate::parse_camt053(&xml).unwrap().statements[0]
.entries
.remove(0)
}
#[test]
fn an_itemised_detail_carries_its_own_signed_amount() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">125.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<NtryDtls>
<TxDtls><Amt Ccy="EUR">100.00</Amt></TxDtls>
<TxDtls><Amt Ccy="EUR">25.00</Amt></TxDtls>
</NtryDtls>
</Ntry>"#,
);
assert!(e.batch_booked);
assert_eq!(e.details[0].signed_ct(), Some(10_000));
assert_eq!(e.details[1].signed_ct(), Some(2_500));
assert_eq!(e.details_signed_sum_ct(), Some(12_500));
assert!(e.details_reconcile());
}
#[test]
fn a_batch_with_no_itemised_amounts_reports_none_rather_than_the_entry_total() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">125.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<NtryDtls>
<TxDtls><Refs><EndToEndId>E1</EndToEndId></Refs></TxDtls>
<TxDtls><Refs><EndToEndId>E2</EndToEndId></Refs></TxDtls>
</NtryDtls>
</Ntry>"#,
);
assert_eq!(e.details[0].signed_ct(), None);
assert_eq!(e.details[1].signed_ct(), None);
assert_eq!(e.details_signed_sum_ct(), None);
assert!(!e.details_reconcile());
}
#[test]
fn a_sole_detail_inherits_the_entry_amount() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">75.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<NtryDtls><TxDtls><Refs><MndtId>MND-1</MndtId></Refs></TxDtls></NtryDtls>
</Ntry>"#,
);
assert!(!e.batch_booked);
assert_eq!(e.details[0].amount_ct, None, "nothing was itemised");
assert_eq!(e.details[0].signed_ct(), Some(-7_500));
assert!(e.details_reconcile());
}
#[test]
fn a_detail_level_indicator_overrides_the_entry_level_one() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">75.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<NtryDtls>
<TxDtls><Amt Ccy="EUR">100.00</Amt></TxDtls>
<TxDtls>
<Amt Ccy="EUR">25.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<RtrInf><Rsn><Cd>MD01</Cd></Rsn></RtrInf>
</TxDtls>
</NtryDtls>
</Ntry>"#,
);
assert_eq!(e.details[0].indicator, CreditDebitIndicator::Credit);
assert_eq!(e.details[1].indicator, CreditDebitIndicator::Debit);
assert_eq!(e.details[1].signed_ct(), Some(-2_500));
assert_eq!(e.details_signed_sum_ct(), Some(7_500));
assert!(e.details_reconcile());
assert!(e.is_return());
assert!(e.details[1].is_return());
}
#[test]
fn amt_dtls_tx_amt_stands_in_for_a_missing_tx_amount() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">30.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<NtryDtls>
<TxDtls><AmtDtls><TxAmt><Amt Ccy="EUR">10.00</Amt></TxAmt></AmtDtls></TxDtls>
<TxDtls><AmtDtls><TxAmt><Amt Ccy="EUR">20.00</Amt></TxAmt></AmtDtls></TxDtls>
</NtryDtls>
</Ntry>"#,
);
assert_eq!(e.details[0].signed_ct(), Some(-1_000));
assert_eq!(e.details[1].signed_ct(), Some(-2_000));
assert!(e.details_reconcile());
}
#[test]
fn a_foreign_currency_detail_is_reported_but_not_summed() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">92.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<NtryDtls><TxDtls><Amt Ccy="USD">100.00</Amt></TxDtls></NtryDtls>
</Ntry>"#,
);
assert_eq!(e.details[0].amount_ct, Some(10_000));
assert_eq!(e.details[0].currency.as_deref(), Some("USD"));
assert_eq!(e.details[0].signed_ct(), None);
assert!(!e.details_reconcile());
}
#[test]
fn dates_are_typed_whichever_choice_form_the_bank_used() {
let bare = entry(
r#"<Ntry>
<Amt Ccy="EUR">10.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<BookgDt><Dt>2026-07-14</Dt></BookgDt>
<ValDt><Dt>2026-07-15</Dt></ValDt>
</Ntry>"#,
);
let stamped = entry(
r#"<Ntry>
<Amt Ccy="EUR">10.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<BookgDt><DtTm>2026-07-14T09:14:00</DtTm></BookgDt>
</Ntry>"#,
);
let day = crate::IsoDate::new(2026, 7, 14).unwrap();
assert_eq!(bare.booking_date(), Some(day));
assert_eq!(stamped.booking_date(), Some(day));
assert_eq!(
bare.value_date(),
Some(crate::IsoDate::new(2026, 7, 15).unwrap())
);
assert_eq!(
stamped.booking_date_raw.as_deref(),
Some("2026-07-14T09:14:00")
);
let absent = entry(r#"<Ntry><Amt Ccy="EUR">10.00</Amt></Ntry>"#);
assert_eq!(absent.booking_date(), None);
let nonsense = entry(
r#"<Ntry><Amt Ccy="EUR">10.00</Amt><BookgDt><Dt>14.07.2026</Dt></BookgDt></Ntry>"#,
);
assert_eq!(nonsense.booking_date(), None);
assert_eq!(nonsense.booking_date_raw.as_deref(), Some("14.07.2026"));
}
#[test]
fn a_return_fee_is_read_at_either_level_and_in_either_shape() {
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>
<TtlChrgsAndTaxAmt Ccy="EUR">3.00</TtlChrgsAndTaxAmt>
<Rcrd><Amt Ccy="EUR">3.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<ChrgInclInd>false</ChrgInclInd>
<Tp><Prtry>RETURN_FEE</Prtry></Tp></Rcrd>
</Chrgs>
<NtryDtls><TxDtls>
<Amt Ccy="EUR">75.00</Amt>
<RtrInf><Rsn><Cd>MS02</Cd></Rsn></RtrInf>
<Chrgs><Rcrd><Amt Ccy="EUR">1.50</Amt><CdtDbtInd>DBIT</CdtDbtInd></Rcrd></Chrgs>
</TxDtls></NtryDtls>
</Ntry>
</Stmt></BkToCstmrStmt>
</Document>"#;
let doc = crate::parse_camt053(xml).unwrap();
let entry = &doc.statements[0].entries[0];
let charges = entry.charges.as_ref().expect("Ntry/Chrgs must be read");
assert_eq!(charges.total_ct, Some(300));
assert_eq!(charges.total_signed_ct(), Some(-300));
assert_eq!(charges.records[0].type_code.as_deref(), Some("RETURN_FEE"));
assert!(!charges.all_included_in_amount());
assert_eq!(charges.records[0].included_in_amount, Some(false));
let detail = &entry.details[0];
assert!(detail.is_return());
assert_eq!(
detail.charges.as_ref().unwrap().total_signed_ct(),
Some(-150)
);
let flat = xml.replace(
"<Chrgs><Rcrd><Amt Ccy=\"EUR\">1.50</Amt><CdtDbtInd>DBIT</CdtDbtInd></Rcrd></Chrgs>",
"<Chrgs><Amt Ccy=\"EUR\">1.50</Amt><CdtDbtInd>DBIT</CdtDbtInd></Chrgs>",
);
let doc = crate::parse_camt053(&flat).unwrap();
let detail = &doc.statements[0].entries[0].details[0];
assert_eq!(
detail.charges.as_ref().unwrap().total_signed_ct(),
Some(-150)
);
}
#[test]
fn a_charge_that_does_not_say_whether_it_is_included_is_not_assumed_to_be() {
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">10.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<Chrgs><Rcrd><Amt Ccy="EUR">2.00</Amt></Rcrd></Chrgs>
</Ntry>
</Stmt></BkToCstmrStmt>
</Document>"#;
let doc = crate::parse_camt053(xml).unwrap();
let charges = doc.statements[0].entries[0].charges.as_ref().unwrap();
assert_eq!(charges.records[0].included_in_amount, None);
assert!(!charges.all_included_in_amount());
assert_eq!(charges.records[0].signed_ct(), -200);
}
#[test]
fn the_btch_element_identifies_the_submitted_group() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">125.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<NtryDtls>
<Btch>
<MsgId>DD-2026-07</MsgId>
<PmtInfId>DD-2026-07-1</PmtInfId>
<NbOfTxs>2</NbOfTxs>
</Btch>
<TxDtls><Amt Ccy="EUR">100.00</Amt></TxDtls>
<TxDtls><Amt Ccy="EUR">25.00</Amt></TxDtls>
</NtryDtls>
</Ntry>"#,
);
let batch = e.batch.as_ref().unwrap();
assert_eq!(batch.message_id.as_deref(), Some("DD-2026-07"));
assert_eq!(batch.payment_info_id.as_deref(), Some("DD-2026-07-1"));
assert_eq!(batch.transaction_count, Some(2));
assert!(e.batch_booked);
assert!(e.details_reconcile());
}
#[test]
fn a_batch_of_one_is_still_a_batch() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">125.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<NtryDtls>
<Btch><PmtInfId>DD-1</PmtInfId><NbOfTxs>3</NbOfTxs></Btch>
<TxDtls><Refs><EndToEndId>E1</EndToEndId></Refs></TxDtls>
</NtryDtls>
</Ntry>"#,
);
assert!(e.batch_booked, "Btch is the bank's own assertion");
assert_eq!(
e.details[0].signed_ct(),
None,
"3 transactions were aggregated; the entry total is not this one's"
);
assert!(!e.details_reconcile());
let single = entry(
r#"<Ntry>
<Amt Ccy="EUR">75.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<NtryDtls>
<Btch><NbOfTxs>1</NbOfTxs></Btch>
<TxDtls><Refs><MndtId>MND-1</MndtId></Refs></TxDtls>
</NtryDtls>
</Ntry>"#,
);
assert!(single.batch_booked);
assert_eq!(single.details[0].signed_ct(), Some(-7_500));
}
#[test]
fn the_banks_free_text_survives_at_both_levels() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">75.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<AddtlNtryInf>SEPA-LASTSCHRIFT EINZUG</AddtlNtryInf>
<NtryDtls><TxDtls>
<AddtlTxInf>Kundennummer 4711</AddtlTxInf>
<RtrInf><Rsn><Cd>AM04</Cd></Rsn><AddtlInf>Konto nicht gedeckt</AddtlInf></RtrInf>
</TxDtls></NtryDtls>
</Ntry>"#,
);
assert_eq!(
e.additional_info.as_deref(),
Some("SEPA-LASTSCHRIFT EINZUG")
);
assert_eq!(
e.details[0].additional_info.as_deref(),
Some("Kundennummer 4711")
);
assert_eq!(e.details[0].return_reason_code.as_deref(), Some("AM04"));
assert_eq!(
e.details[0].return_additional_info.as_deref(),
Some("Konto nicht gedeckt")
);
}
#[test]
fn a_remittance_split_across_several_ustrd_is_read_whole() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">75.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<NtryDtls><TxDtls><RmtInf>
<Ustrd>Rechnung 2026-07 Teilzahlung 1 von</Ustrd>
<Ustrd>3, Kundennummer 4711</Ustrd>
<Ustrd>RG-NR 2026-000815</Ustrd>
</RmtInf></TxDtls></NtryDtls>
</Ntry>"#,
);
assert_eq!(
e.reference(),
Some("Rechnung 2026-07 Teilzahlung 1 von 3, Kundennummer 4711 RG-NR 2026-000815")
);
let one = entry(
r#"<Ntry><Amt Ccy="EUR">1.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<NtryDtls><TxDtls><RmtInf><Ustrd>Miete Juli</Ustrd></RmtInf></TxDtls></NtryDtls>
</Ntry>"#,
);
assert_eq!(one.reference(), Some("Miete Juli"));
let none = entry(
r#"<Ntry><Amt Ccy="EUR">1.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<NtryDtls><TxDtls><RmtInf><Ustrd/></RmtInf></TxDtls></NtryDtls>
</Ntry>"#,
);
assert_eq!(none.reference(), None);
}
#[test]
fn an_entry_without_details_reconciles_trivially() {
let e = entry(r#"<Ntry><Amt Ccy="EUR">10.00</Amt><CdtDbtInd>CRDT</CdtDbtInd></Ntry>"#);
assert!(e.details.is_empty());
assert_eq!(e.details_signed_sum_ct(), Some(0));
assert!(e.details_reconcile());
}
#[test]
fn a_mismatched_batch_is_reported_as_not_reconciling() {
let e = entry(
r#"<Ntry>
<Amt Ccy="EUR">125.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<NtryDtls>
<TxDtls><Amt Ccy="EUR">100.00</Amt></TxDtls>
<TxDtls><Amt Ccy="EUR">20.00</Amt></TxDtls>
</NtryDtls>
</Ntry>"#,
);
assert_eq!(e.details_signed_sum_ct(), Some(12_000));
assert_eq!(e.signed_ct(), 12_500);
assert!(!e.details_reconcile());
}
}