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 {
match self.indicator {
CreditDebitIndicator::Credit => self.amount_ct,
CreditDebitIndicator::Debit => -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>,
}
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)]
#[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 booking_date_raw: Option<String>,
pub value_date_raw: Option<String>,
pub account_servicer_ref: Option<String>,
pub bank_tx_code: Option<String>,
pub details: Vec<EntryDetail>,
}
impl CashEntry {
#[inline]
#[must_use]
pub fn signed_ct(&self) -> i64 {
match self.indicator {
CreditDebitIndicator::Credit => self.amount_ct,
CreditDebitIndicator::Debit => -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()?;
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,
}
}
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_booked = detail_count > 1;
let details = details_parent
.children_named("TxDtls")
.map(|td| {
parse_detail(
td,
&EntryContext {
indicator,
amount_ct,
currency: ¤cy,
sole_detail: detail_count == 1,
},
)
})
.collect();
Some(CashEntry {
amount_ct,
currency,
indicator,
status,
batch_booked,
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,
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.abs())),
None if entry.sole_detail => Some(signed(indicator, entry.amount_ct.abs())),
None => None,
};
EntryDetail {
amount_ct: reported.as_ref().map(|(ct, _)| ct.abs()),
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.text_at(&["RmtInf", "Ustrd"]).map(str::to_owned),
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),
}
}
pub(crate) fn account_iban(node: &Node) -> String {
node.path(&["Acct", "Id", "IBAN"])
.map(|n| n.text.clone())
.unwrap_or_default()
}
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 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());
}
}