use crate::camt054::CreditDebitIndicator;
use crate::xml::Node;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FinancialIssue {
MissingAmount,
InvalidAmount,
InvalidCount,
NegativeAmount,
SubCentPrecision,
Overflow,
MissingCurrency,
InvalidCurrency,
MissingDirection,
InvalidDirection,
InconsistentValue,
ConflictingAlternatives,
CurrencyMismatch,
TotalMismatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum OriginalAmountSource {
OriginalInstructed,
TransactionReference,
LegacyTransactionReference,
}
impl OriginalAmountSource {
pub(crate) const fn path(self) -> &'static str {
match self {
Self::OriginalInstructed => "OrgnlInstdAmt",
Self::TransactionReference => "OrgnlTxRef/Amt/InstdAmt",
Self::LegacyTransactionReference => "OrgnlTxRef/InstdAmt",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DetailAmountSource {
Direct,
TransactionAmount,
}
impl DetailAmountSource {
pub(crate) const fn path(self) -> &'static str {
match self {
Self::Direct => "Amt",
Self::TransactionAmount => "AmtDtls/TxAmt/Amt",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FinancialDiagnostic {
pub path: String,
pub issue: FinancialIssue,
pub raw: Option<String>,
}
pub(crate) fn currency_diagnostics(path: &str, raw: Option<&str>) -> Vec<FinancialDiagnostic> {
let issue = match raw {
None => Some(FinancialIssue::MissingCurrency),
Some(raw)
if crate::Currency::new(raw)
.ok()
.is_none_or(|currency| currency.code() != raw) =>
{
Some(FinancialIssue::InvalidCurrency)
}
Some(_) => None,
};
issue
.into_iter()
.map(|issue| FinancialDiagnostic {
path: path.to_owned(),
issue,
raw: raw.map(str::to_owned),
})
.collect()
}
pub(crate) fn decimal_diagnostics(
path: &str,
raw: Option<&str>,
interpreted: Option<i64>,
nonnegative: bool,
) -> Vec<FinancialDiagnostic> {
let issue = match raw.map(crate::ct_from_eur_str) {
None if interpreted.is_none() => Some(FinancialIssue::MissingAmount),
None => interpreted
.filter(|value| nonnegative && *value < 0)
.map(|_| FinancialIssue::NegativeAmount),
Some(Err(crate::AmountError::SubCentPrecision { .. })) => {
Some(FinancialIssue::SubCentPrecision)
}
Some(Err(crate::AmountError::Overflow { .. })) => Some(FinancialIssue::Overflow),
Some(Err(_)) => Some(FinancialIssue::InvalidAmount),
Some(Ok(value)) if nonnegative && value < 0 => Some(FinancialIssue::NegativeAmount),
Some(Ok(value)) if Some(value) != interpreted => Some(FinancialIssue::InconsistentValue),
Some(Ok(_)) => None,
};
issue
.into_iter()
.map(|issue| FinancialDiagnostic {
path: path.to_owned(),
issue,
raw: raw.map(str::to_owned),
})
.collect()
}
pub(crate) fn optional_amount_diagnostics(
path: &str,
raw: Option<&str>,
ct: Option<i64>,
currency: Option<&str>,
) -> Vec<FinancialDiagnostic> {
if raw.is_none() && ct.is_none() && currency.is_none() {
return Vec::new();
}
ReportedAmount {
ct,
amount_raw: raw.map(str::to_owned),
currency: currency.map(str::to_owned),
..Default::default()
}
.diagnostics(path, None)
}
pub(crate) fn optional_count_diagnostics(
path: &str,
raw: Option<&str>,
count: Option<u64>,
) -> Vec<FinancialDiagnostic> {
let issue = match raw {
Some(raw) => match raw.trim().parse::<u64>() {
Ok(value) if Some(value) == count => None,
Ok(_) => Some(FinancialIssue::InconsistentValue),
Err(error) if matches!(error.kind(), std::num::IntErrorKind::PosOverflow) => {
Some(FinancialIssue::Overflow)
}
Err(_) => Some(FinancialIssue::InvalidCount),
},
None => count.map(|_| FinancialIssue::InconsistentValue),
};
issue.map_or_else(Vec::new, |issue| {
vec![FinancialDiagnostic {
path: path.to_owned(),
issue,
raw: raw.map(str::to_owned),
}]
})
}
pub(crate) fn optional_decimal_diagnostics(
path: &str,
raw: Option<&str>,
ct: Option<i64>,
) -> Vec<FinancialDiagnostic> {
if raw.is_none() && ct.is_none() {
Vec::new()
} else {
decimal_diagnostics(path, raw, ct, false)
}
}
fn charge_diagnostics(path: &str, charges: &Charges, result: &mut Vec<FinancialDiagnostic>) {
if charges.has_mixed_sources() {
result.push(FinancialDiagnostic {
path: path.to_owned(),
issue: FinancialIssue::ConflictingAlternatives,
raw: None,
});
}
let aggregate = format!("{path}/TtlChrgsAndTaxAmt");
result.extend(optional_amount_diagnostics(
&aggregate,
charges.total_amount_raw.as_deref(),
charges.total_ct,
charges.total_currency.as_deref(),
));
let currency = charges
.records
.first()
.and_then(|record| record.amount.currency.as_deref());
for (index, record) in charges.records.iter().enumerate() {
let record_path = if record.source == Some(ChargeRecordSource::LegacyFlat) {
path.to_owned()
} else {
format!("{path}/Rcrd[{index}]")
};
result.extend(record.amount.diagnostics(
&format!("{record_path}/Amt"),
Some(&format!("{record_path}/CdtDbtInd")),
));
if let (Some(expected), Some(actual)) = (currency, record.amount.currency.as_deref())
&& expected != actual
{
result.push(FinancialDiagnostic {
path: format!("{record_path}/Amt/@Ccy"),
issue: FinancialIssue::CurrencyMismatch,
raw: Some(actual.to_owned()),
});
}
}
if let (Some(expected), Some(actual)) = (currency, charges.total_currency.as_deref())
&& charges.total_amount_raw.is_some()
&& expected != actual
{
result.push(FinancialDiagnostic {
path: format!("{aggregate}/@Ccy"),
issue: FinancialIssue::CurrencyMismatch,
raw: Some(actual.to_owned()),
});
}
if let Some(total) = charges.total_ct {
let comparable = charges.total_currency.as_deref().is_some_and(|currency| {
charges.records.iter().all(|record| {
record.amount.currency.as_deref() == Some(currency)
&& record.amount.diagnostics("", None).is_empty()
})
}) && optional_amount_diagnostics(
"",
charges.total_amount_raw.as_deref(),
charges.total_ct,
charges.total_currency.as_deref(),
)
.is_empty();
let sum = charges.records.iter().try_fold(0_i128, |sum, record| {
sum.checked_add(i128::from(record.amount.ct?))
});
if comparable
&& !charges.has_mixed_sources()
&& !charges.records.is_empty()
&& sum.is_some_and(|sum| sum != i128::from(total))
{
result.push(FinancialDiagnostic {
path: aggregate,
issue: FinancialIssue::TotalMismatch,
raw: charges.total_amount_raw.clone(),
});
}
}
}
pub(crate) fn cash_diagnostics(
path: &str,
balances: &[StatementBalance],
entries: &[CashEntry],
) -> Vec<FinancialDiagnostic> {
let mut result = Vec::new();
for (index, balance) in balances.iter().enumerate() {
let location = format!("{path}/Bal[{index}]");
result.extend(balance.amount.diagnostics(
&format!("{location}/Amt"),
Some(&format!("{location}/CdtDbtInd")),
));
}
let currency = entries
.first()
.and_then(|entry| entry.amount.currency.as_deref());
for (index, entry) in entries.iter().enumerate() {
let location = format!("{path}/Ntry[{index}]");
result.extend(entry.amount.diagnostics(
&format!("{location}/Amt"),
Some(&format!("{location}/CdtDbtInd")),
));
if let (Some(expected), Some(actual)) = (currency, entry.amount.currency.as_deref())
&& expected != actual
{
result.push(FinancialDiagnostic {
path: format!("{location}/Amt/@Ccy"),
issue: FinancialIssue::CurrencyMismatch,
raw: Some(actual.to_owned()),
});
}
if let Some(charges) = &entry.charges {
charge_diagnostics(&format!("{location}/Chrgs"), charges, &mut result);
}
for (index, detail) in entry.details.iter().enumerate() {
let detail_path = format!("{location}/NtryDtls/TxDtls[{index}]");
let source = detail
.amount_source
.map_or("amount", DetailAmountSource::path);
if detail.amount_source.is_some()
|| detail.amount.amount_raw.is_some()
|| detail.amount.ct.is_some()
|| detail.amount.currency.is_some()
{
result.extend(detail.amount.diagnostics(
&format!("{detail_path}/{source}"),
Some(&format!("{detail_path}/CdtDbtInd")),
));
}
if let Some(transaction) = &detail.transaction_amount {
if detail.amount_source != Some(DetailAmountSource::TransactionAmount) {
result.extend(
transaction.diagnostics(&format!("{detail_path}/AmtDtls/TxAmt/Amt"), None),
);
}
if detail
.amount
.ct
.zip(transaction.ct)
.is_some_and(|(direct, nested)| direct != nested)
|| detail
.amount
.currency
.as_deref()
.zip(transaction.currency.as_deref())
.is_some_and(|(direct, nested)| direct != nested)
{
result.push(FinancialDiagnostic {
path: detail_path.clone(),
issue: FinancialIssue::ConflictingAlternatives,
raw: None,
});
}
}
if detail
.amount
.currency
.as_deref()
.or(detail.signed_currency.as_deref())
.zip(entry.amount.currency.as_deref())
.is_some_and(|(own, parent)| own != parent)
{
result.push(FinancialDiagnostic {
path: format!("{detail_path}/{source}/@Ccy"),
issue: FinancialIssue::CurrencyMismatch,
raw: detail.amount.currency.clone(),
});
}
if let Some(charges) = &detail.charges {
charge_diagnostics(&format!("{detail_path}/Chrgs"), charges, &mut result);
}
}
}
result
}
#[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),
Unspecified,
}
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,
Self::Unspecified => "",
}
}
pub(crate) fn from_code(s: &str) -> Self {
match s.trim() {
"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() {
"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: ReportedAmount,
pub date_raw: String,
}
impl StatementBalance {
#[inline]
#[must_use]
pub fn signed_ct(&self) -> Option<i64> {
self.amount.signed_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 ReportedAmount {
pub ct: Option<i64>,
pub currency: Option<String>,
pub amount_raw: Option<String>,
pub direction: Option<CreditDebitIndicator>,
pub direction_raw: Option<String>,
}
pub(crate) fn net_movement_ct(entries: &[CashEntry]) -> Option<i64> {
let mut currency = None;
let total = entries.iter().try_fold(0_i128, |total, entry| {
let denomination = entry.amount.currency.as_deref()?;
if currency.is_some_and(|previous| previous != denomination) {
return None;
}
currency = Some(denomination);
total.checked_add(i128::from(entry.signed_ct()?))
})?;
i64::try_from(total).ok()
}
impl ReportedAmount {
#[must_use]
pub fn diagnostics(
&self,
amount_path: &str,
direction_path: Option<&str>,
) -> Vec<FinancialDiagnostic> {
let mut result =
decimal_diagnostics(amount_path, self.amount_raw.as_deref(), self.ct, true);
result.extend(currency_diagnostics(
&format!("{amount_path}/@Ccy"),
self.currency.as_deref(),
));
if let Some(path) = direction_path {
let issue = match (self.direction_raw.as_deref(), self.direction) {
(None, None) => Some(FinancialIssue::MissingDirection),
(Some(raw), _) if raw.parse::<CreditDebitIndicator>().is_err() => {
Some(FinancialIssue::InvalidDirection)
}
(Some(raw), direction) if raw.parse::<CreditDebitIndicator>().ok() != direction => {
Some(FinancialIssue::InconsistentValue)
}
_ => None,
};
if let Some(issue) = issue {
result.push(FinancialDiagnostic {
path: path.to_owned(),
issue,
raw: self.direction_raw.clone(),
});
}
}
result
}
#[inline]
#[must_use]
pub fn signed_ct(&self) -> Option<i64> {
let currency = self.currency.as_deref()?;
if crate::Currency::new(currency).ok()?.code() != currency {
return None;
}
let direction = self.direction?;
let magnitude = self.ct?;
if self
.amount_raw
.as_deref()
.is_some_and(|raw| crate::ct_from_eur_str(raw).ok() != Some(magnitude))
|| self
.direction_raw
.as_deref()
.is_some_and(|raw| raw.parse::<CreditDebitIndicator>().ok() != Some(direction))
{
return None;
}
signed(direction, magnitude)
}
#[inline]
#[must_use]
pub fn is_resolved(&self) -> bool {
self.signed_ct().is_some()
}
#[must_use]
pub fn is_currency(&self, currency: Option<&str>) -> bool {
match (self.currency.as_deref(), currency) {
(Some(a), Some(b)) => a.eq_ignore_ascii_case(b),
_ => false,
}
}
pub(crate) fn parse(node: &Node, tag: &str) -> Self {
let (ct, currency, amount_raw) = match node.child(tag) {
None => (None, None, None),
Some(amt) => (
crate::ct_from_eur_str(&amt.text)
.ok()
.filter(|value| *value >= 0),
amt.attr("Ccy").map(str::to_owned),
Some(amt.text.clone()),
),
};
let (direction, direction_raw) = match node.raw_text_of("CdtDbtInd") {
None => (None, None),
Some(raw) => (raw.parse().ok(), Some(raw.to_owned())),
};
Self {
ct,
currency,
amount_raw,
direction,
direction_raw,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EntryDetail {
pub amount: ReportedAmount,
pub amount_source: Option<DetailAmountSource>,
pub transaction_amount: Option<ReportedAmount>,
#[cfg_attr(feature = "serde", serde(skip_deserializing))]
signed_amount_ct: Option<i64>,
#[cfg_attr(feature = "serde", serde(skip_deserializing))]
signed_currency: Option<String>,
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 fn signed_ct(&self) -> Option<i64> {
if let Some(transaction) = &self.transaction_amount {
let nested = transaction.signed_ct()?;
if self.amount.currency != transaction.currency
|| self.amount.signed_ct() != Some(nested)
{
return None;
}
}
let cached = self.signed_amount_ct?;
let currency = self.signed_currency.as_deref()?;
if self.amount.amount_raw.is_some() {
if self.amount.currency.as_deref() != Some(currency)
|| self.amount.signed_ct() != Some(cached)
{
return None;
}
} else {
let direction = self.amount.direction?;
if self.amount.ct.is_some()
|| self.amount.currency.is_some()
|| signed(direction, cached.checked_abs()?) != Some(cached)
|| self
.amount
.direction_raw
.as_deref()
.is_some_and(|raw| raw.parse::<CreditDebitIndicator>().ok() != Some(direction))
{
return None;
}
}
Some(cached)
}
#[inline]
#[must_use]
pub const fn is_return(&self) -> bool {
self.return_reason_code.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ChargeRecordSource {
Record,
LegacyFlat,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ChargeRecord {
pub source: Option<ChargeRecordSource>,
pub amount: ReportedAmount,
pub included_in_amount: Option<bool>,
pub included_in_amount_raw: Option<String>,
pub type_code: Option<String>,
}
impl ChargeRecord {
#[inline]
#[must_use]
pub fn signed_ct(&self) -> Option<i64> {
self.amount.signed_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 total_amount_raw: Option<String>,
pub records: Vec<ChargeRecord>,
}
impl Charges {
#[must_use]
pub fn total_signed_ct(&self) -> Option<i64> {
if self.has_mixed_sources() {
return None;
}
let currency = self.records.first()?.amount.currency.as_deref()?;
if self.total_amount_raw.is_some() {
if !self
.total_currency
.as_deref()?
.eq_ignore_ascii_case(currency)
{
return None;
}
let magnitude = self.records.iter().try_fold(0i64, |sum, record| {
let magnitude = record.amount.ct.filter(|value| *value >= 0)?;
sum.checked_add(magnitude)
})?;
if self.total_ct? != magnitude {
return None;
}
}
self.records.iter().try_fold(0i64, |acc, record| {
if !record.amount.is_currency(Some(currency)) {
return None;
}
acc.checked_add(record.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 has_mixed_sources(&self) -> bool {
self.records
.iter()
.any(|record| record.source == Some(ChargeRecordSource::LegacyFlat))
&& self
.records
.iter()
.any(|record| record.source != Some(ChargeRecordSource::LegacyFlat))
}
fn parse(node: &Node) -> Option<Self> {
let chrgs = node.child("Chrgs")?;
let record_of = |n: &Node, source| {
let amount = ReportedAmount::parse(n, "Amt");
let incl_raw = n.raw_text_of("ChrgInclInd");
ChargeRecord {
source: Some(source),
amount,
included_in_amount: incl_raw.and_then(|v| match v.trim() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
}),
included_in_amount_raw: incl_raw.map(str::to_owned),
type_code: n.child("Tp").and_then(Node::code).map(str::to_owned),
}
};
let mut records: Vec<ChargeRecord> = chrgs
.children_named("Rcrd")
.map(|node| record_of(node, ChargeRecordSource::Record))
.collect();
let flat = record_of(chrgs, ChargeRecordSource::LegacyFlat);
if flat.amount.amount_raw.is_some() {
records.push(flat);
}
let total = ReportedAmount::parse(chrgs, "TtlChrgsAndTaxAmt");
if records.is_empty() && total.amount_raw.is_none() {
return None;
}
Some(Self {
total_ct: total.ct,
total_currency: total.currency,
total_amount_raw: total.amount_raw,
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: ReportedAmount,
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) -> Option<i64> {
self.amount.signed_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> {
if self.details.is_empty() {
return Some(0);
}
let currency = self.amount.currency.as_deref()?;
let total = self.details.iter().try_fold(0_i128, |sum, detail| {
if detail.signed_currency.as_deref() != Some(currency) {
return None;
}
sum.checked_add(i128::from(detail.signed_ct()?))
})?;
i64::try_from(total).ok()
}
#[must_use]
pub fn details_reconcile(&self) -> bool {
if self.details.is_empty() {
return true;
}
match (self.details_signed_sum_ct(), self.signed_ct()) {
(Some(sum), Some(total)) => sum == total,
_ => false,
}
}
}
fn parse_date(raw: Option<&str>) -> Option<crate::IsoDate> {
crate::IsoDate::parse_date_part(raw?).ok()
}
const fn signed(indicator: CreditDebitIndicator, amount_ct: i64) -> Option<i64> {
if amount_ct < 0 {
return None;
}
match indicator {
CreditDebitIndicator::Credit => Some(amount_ct),
CreditDebitIndicator::Debit => amount_ct.checked_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) -> StatementBalance {
let balance_type = b
.path(&["Tp", "CdOrPrtry"])
.and_then(Node::code)
.map_or(BalanceType::Unspecified, BalanceType::from_code);
let date = b
.child("Dt")
.and_then(|d| d.text_of("Dt").or_else(|| d.text_of("DtTm")))
.unwrap_or_default()
.to_owned();
StatementBalance {
balance_type,
amount: ReportedAmount::parse(b, "Amt"),
date_raw: date,
}
}
pub(crate) fn parse_entry(e: &Node) -> CashEntry {
let amount = ReportedAmount::parse(e, "Amt");
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 {
amount: &amount,
sole_detail,
},
)
})
.collect();
CashEntry {
amount,
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) amount: &'a ReportedAmount,
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 mut amount = ReportedAmount::parse(td, "Amt");
let mut amount_source = td.child("Amt").map(|_| DetailAmountSource::Direct);
if amount.direction_raw.is_none() {
amount.direction = entry.amount.direction;
}
let indicator = amount.direction;
let parties = td.child("RltdPties");
let (name_tag, acct_tag) = match indicator {
Some(CreditDebitIndicator::Credit) | None => ("Dbtr", "DbtrAcct"),
Some(CreditDebitIndicator::Debit) => ("Cdtr", "CdtrAcct"),
};
let transaction_amount = td.path(&["AmtDtls", "TxAmt"]).map(|transaction| {
let mut reported = ReportedAmount::parse(transaction, "Amt");
reported.direction = amount.direction;
reported.direction_raw.clone_from(&amount.direction_raw);
reported
});
if amount.amount_raw.is_none()
&& let Some(fallback) = &transaction_amount
{
amount.ct = fallback.ct;
amount.currency.clone_from(&fallback.currency);
amount.amount_raw.clone_from(&fallback.amount_raw);
amount_source = Some(DetailAmountSource::TransactionAmount);
}
let signed_amount_ct = if amount.amount_raw.is_some() {
if amount.is_currency(entry.amount.currency.as_deref()) {
amount.signed_ct()
} else {
None
}
} else if entry.sole_detail && amount_source.is_none() {
entry.amount.signed_ct()
} else {
None
};
EntryDetail {
amount,
amount_source,
transaction_amount,
signed_amount_ct,
signed_currency: entry.amount.currency.clone(),
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").map(parse_entry).collect()
}
pub(crate) fn balances_of(node: &Node) -> Vec<StatementBalance> {
node.children_named("Bal").map(parse_balance).collect()
}
#[cfg(test)]
mod tests {
use super::{CashEntry, CreditDebitIndicator};
#[test]
fn decoded_money_is_retained_and_conflicting_public_facts_are_unresolved() {
let mut parsed =
entry("<Ntry><Amt Ccy=\"EUR\"> \n1.00\t </Amt><CdtDbtInd>CRDT</CdtDbtInd></Ntry>");
assert_eq!(parsed.amount.amount_raw.as_deref(), Some(" \n1.00\t "));
assert_eq!(parsed.signed_ct(), Some(100));
parsed.amount.ct = Some(200);
assert_eq!(parsed.signed_ct(), None);
parsed.amount.ct = Some(100);
parsed.amount.direction = Some(CreditDebitIndicator::Debit);
assert_eq!(parsed.signed_ct(), None);
let mut detail = entry("<Ntry><Amt Ccy=\"EUR\">1.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><NtryDtls><TxDtls><Amt Ccy=\"EUR\">1.00</Amt></TxDtls></NtryDtls></Ntry>").details.remove(0);
detail.amount.ct = Some(-100);
assert_eq!(detail.signed_ct(), None);
let mut inherited = entry("<Ntry><Amt Ccy=\"EUR\">1.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><NtryDtls><TxDtls/></NtryDtls></Ntry>").details.remove(0);
assert_eq!(inherited.signed_ct(), Some(100));
inherited.amount.direction = Some(CreditDebitIndicator::Debit);
assert_eq!(inherited.signed_ct(), None);
}
#[cfg(feature = "serde")]
#[test]
fn restoration_cannot_inject_a_trusted_detail_cache() {
let detail = entry("<Ntry><Amt Ccy=\"EUR\">1.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><NtryDtls><TxDtls/></NtryDtls></Ntry>").details.remove(0);
assert_eq!(detail.signed_ct(), Some(100));
let mut value = serde_json::to_value(&detail).unwrap();
value["signed_amount_ct"] = serde_json::json!(900);
let restored: super::EntryDetail = serde_json::from_value(value).unwrap();
assert_eq!(restored.signed_ct(), None);
}
#[test]
fn invalid_magnitudes_and_missing_charge_directions_never_resolve() {
for magnitude in [-1, i64::MIN] {
let amount = super::ReportedAmount {
ct: Some(magnitude),
currency: Some("EUR".to_owned()),
direction: Some(CreditDebitIndicator::Debit),
..Default::default()
};
assert_eq!(amount.signed_ct(), None);
assert!(!amount.is_resolved());
}
let parsed = entry("<Ntry><Chrgs><Rcrd><Amt Ccy=\"EUR\">3.00</Amt></Rcrd></Chrgs></Ntry>");
assert_eq!(parsed.charges.unwrap().total_signed_ct(), None);
let parsed = entry("<Ntry><Amt Ccy=\"EUR\">-1.00</Amt><CdtDbtInd>DBIT</CdtDbtInd></Ntry>");
assert_eq!(parsed.signed_ct(), None);
}
#[test]
fn unrecorded_charge_totals_and_mixed_currencies_are_not_zero_or_a_sum() {
let parsed = entry(
"<Ntry><Chrgs><TtlChrgsAndTaxAmt Ccy=\"USD\">not-money</TtlChrgsAndTaxAmt></Chrgs></Ntry>",
);
assert!(parsed.charges.is_some());
assert_eq!(parsed.charges.unwrap().total_signed_ct(), None);
let parsed = entry(
"<Ntry><Chrgs><Rcrd><Amt Ccy=\"EUR\">1.00</Amt><CdtDbtInd>DBIT</CdtDbtInd></Rcrd><Rcrd><Amt Ccy=\"USD\">2.00</Amt><CdtDbtInd>DBIT</CdtDbtInd></Rcrd></Chrgs></Ntry>",
);
assert_eq!(parsed.charges.unwrap().total_signed_ct(), None);
}
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><CreDtTm>2026-07-21T23:59:00</CreDtTm></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].amount.direction,
Some(CreditDebitIndicator::Credit)
);
assert_eq!(
e.details[1].amount.direction,
Some(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].amount.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><CreDtTm>2026-07-21T23:59:00</CreDtTm></GrpHdr><Stmt><Id>S</Id><Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct><Bal><Tp><CdOrPrtry><Cd>CLBD</Cd></CdOrPrtry></Tp><Amt Ccy="EUR">0.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><Dt><Dt>2026-07-21</Dt></Dt></Bal>
<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>
<Chrgs>
<TtlChrgsAndTaxAmt Ccy="EUR">3.00</TtlChrgsAndTaxAmt>
<Rcrd><Amt Ccy="EUR">3.00</Amt><CdtDbtInd>DBIT</CdtDbtInd>
<ChrgInclInd>false</ChrgInclInd>
<Tp><Prtry><Id>RETURN_FEE</Id></Prtry></Tp></Rcrd>
</Chrgs>
<NtryDtls><TxDtls>
<Amt Ccy="EUR">75.00</Amt>
<Chrgs><Rcrd><Amt Ccy="EUR">1.50</Amt><CdtDbtInd>DBIT</CdtDbtInd></Rcrd></Chrgs>
<RtrInf><Rsn><Cd>MS02</Cd></Rsn></RtrInf>
</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><CreDtTm>2026-07-21T23:59:00</CreDtTm></GrpHdr><Stmt><Id>S</Id><Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct><Bal><Tp><CdOrPrtry><Cd>CLBD</Cd></CdOrPrtry></Tp><Amt Ccy="EUR">0.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><Dt><Dt>2026-07-21</Dt></Dt></Bal>
<Ntry><Amt Ccy="EUR">10.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>
<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(), None);
}
#[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_eq!(e.details.len(), 0);
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(), Some(12_500));
assert!(!e.details_reconcile());
}
#[test]
fn every_type_that_reports_money_is_covered_by_the_cross_level_gate() {
const SRC: &str = include_str!("camt.rs");
const DELEGATES: &[&str] = &["StatementBalance", "ChargeRecord", "CashEntry"];
const DIVERGES: &[(&str, &str)] = &[
("ReportedAmount", "the definition the others delegate to"),
(
"EntryDetail",
"reports a *resolved* figure that may be inherited from its \
entry, so it is not a function of its own `amount` alone",
),
];
let mut found: Vec<&str> = Vec::new();
let mut current: Option<&str> = None;
for line in SRC.lines() {
if let Some(rest) = line.strip_prefix("impl ") {
current = rest.split_whitespace().next().map(str::trim);
}
if line.contains("fn signed_ct(")
&& let Some(ty) = current
{
found.push(ty);
}
}
found.sort_unstable();
found.dedup();
let mut accounted: Vec<&str> = DELEGATES
.iter()
.copied()
.chain(DIVERGES.iter().map(|(t, _)| *t))
.collect();
accounted.sort_unstable();
assert_eq!(
found, accounted,
"a `signed_ct` in this module is not accounted for.\n\
Add it to DELEGATES (and to the four types named in \
tests/conformance.rs::cross_level_consistency), or to DIVERGES \
with the reason it may answer differently.\n\
found: {found:?}\n\
accounted: {accounted:?}"
);
assert!(
found.len() >= 5,
"the source walk found {} types — it has stopped working",
found.len()
);
}
}