use crate::camt::ReportedAmount;
use crate::date::IsoDate;
use crate::xml::{Document, Node, XmlError};
pub const NAMESPACE: &str = "urn:iso:std:iso:20022:tech:xsd:camt.029.001.06";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CancellationStatus {
Accepted,
Rejected,
Pending,
PartiallyAccepted,
Other(String),
}
impl CancellationStatus {
#[must_use]
pub fn as_code(&self) -> &str {
match self {
Self::Accepted => "ACCR",
Self::Rejected => "RJCR",
Self::Pending => "PDCR",
Self::PartiallyAccepted => "PACR",
Self::Other(s) => s,
}
}
pub(crate) fn from_code(s: &str) -> Self {
match s.trim() {
"ACCR" => Self::Accepted,
"RJCR" => Self::Rejected,
"PDCR" => Self::Pending,
"PACR" => Self::PartiallyAccepted,
other => Self::Other(other.to_owned()),
}
}
#[must_use]
pub const fn is_accepted(&self) -> bool {
matches!(self, Self::Accepted)
}
#[must_use]
pub const fn is_rejected(&self) -> bool {
matches!(self, Self::Rejected)
}
#[must_use]
pub const fn is_final(&self) -> bool {
matches!(
self,
Self::Accepted | Self::Rejected | Self::PartiallyAccepted
)
}
}
impl std::fmt::Display for CancellationStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ResolutionOutcome {
Cancelled,
PartiallyCancelled,
RejectedCancellation,
PendingCancellation,
Duplicate,
CancellationWaitingForFurtherWork,
Information,
Other(String),
}
impl ResolutionOutcome {
#[must_use]
pub fn as_code(&self) -> &str {
match self {
Self::Cancelled => "CNCL",
Self::PartiallyCancelled => "PECR",
Self::RejectedCancellation => "RJCR",
Self::PendingCancellation => "PDCR",
Self::Duplicate => "IDUP",
Self::CancellationWaitingForFurtherWork => "CWFW",
Self::Information => "INFO",
Self::Other(s) => s,
}
}
fn from_code(s: &str) -> Self {
match s.trim() {
"CNCL" => Self::Cancelled,
"PECR" => Self::PartiallyCancelled,
"RJCR" => Self::RejectedCancellation,
"PDCR" => Self::PendingCancellation,
"IDUP" => Self::Duplicate,
"CWFW" => Self::CancellationWaitingForFurtherWork,
"INFO" => Self::Information,
other => Self::Other(other.to_owned()),
}
}
#[must_use]
pub const fn is_accepted(&self) -> bool {
matches!(self, Self::Cancelled)
}
#[must_use]
pub const fn is_rejected(&self) -> bool {
matches!(self, Self::RejectedCancellation)
}
#[must_use]
pub const fn is_final(&self) -> bool {
matches!(
self,
Self::Cancelled | Self::PartiallyCancelled | Self::RejectedCancellation
)
}
}
impl std::fmt::Display for ResolutionOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum RejectionReason {
Legl,
Agnt,
Cust,
Ardt,
Noas,
Noor,
Ac04,
Am04,
Other(String),
}
impl RejectionReason {
#[must_use]
pub fn as_code(&self) -> &str {
match self {
Self::Legl => "LEGL",
Self::Agnt => "AGNT",
Self::Cust => "CUST",
Self::Ardt => "ARDT",
Self::Noas => "NOAS",
Self::Noor => "NOOR",
Self::Ac04 => "AC04",
Self::Am04 => "AM04",
Self::Other(s) => s,
}
}
fn from_code(s: &str) -> Self {
match s.trim() {
"LEGL" => Self::Legl,
"AGNT" => Self::Agnt,
"CUST" => Self::Cust,
"ARDT" => Self::Ardt,
"NOAS" => Self::Noas,
"NOOR" => Self::Noor,
"AC04" => Self::Ac04,
"AM04" => Self::Am04,
other => Self::Other(other.to_owned()),
}
}
#[must_use]
pub const fn is_already_returned(&self) -> bool {
matches!(self, Self::Ardt)
}
}
impl std::fmt::Display for RejectionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CancellationCount {
pub status: Option<CancellationStatus>,
pub status_raw: Option<String>,
pub count: Option<u64>,
pub control_sum_ct: Option<i64>,
pub control_sum_raw: Option<String>,
pub count_raw: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TransactionCancellationStatus {
pub cancellation_status_id: Option<String>,
pub original_end_to_end_id: Option<String>,
pub original_instruction_id: Option<String>,
pub status: Option<CancellationStatus>,
pub reason_codes: Vec<RejectionReason>,
pub additional_info: Vec<String>,
pub original_amount_ct: Option<i64>,
pub original_currency: Option<String>,
pub original_amount_raw: Option<String>,
pub original_amount_source: Option<crate::camt::OriginalAmountSource>,
pub referenced_amount: Option<ReportedAmount>,
pub referenced_amount_source: Option<crate::camt::OriginalAmountSource>,
pub legacy_referenced_amount: Option<ReportedAmount>,
pub original_settlement_amount: Option<ReportedAmount>,
pub referenced_equivalent_amount: Option<ReportedAmount>,
pub referenced_transfer_currency: Option<String>,
pub original_execution_date_raw: Option<String>,
pub original_collection_date_raw: Option<String>,
}
impl TransactionCancellationStatus {
fn financial_diagnostics(&self, path: &str) -> Vec<crate::camt::FinancialDiagnostic> {
let source = self
.original_amount_source
.map_or("original_amount", crate::camt::OriginalAmountSource::path);
let mut result = crate::camt::optional_amount_diagnostics(
&format!("{path}/{source}"),
self.original_amount_raw.as_deref(),
self.original_amount_ct,
self.original_currency.as_deref(),
);
let reference_path = format!("{path}/OrgnlTxRef/Amt");
if let Some(settlement) = &self.original_settlement_amount {
result
.extend(settlement.diagnostics(&format!("{path}/OrgnlTxRef/IntrBkSttlmAmt"), None));
}
if let Some(legacy) = &self.legacy_referenced_amount {
result.extend(legacy.diagnostics(&format!("{path}/OrgnlTxRef/InstdAmt"), None));
result.push(crate::camt::FinancialDiagnostic {
path: format!("{path}/OrgnlTxRef"),
issue: crate::camt::FinancialIssue::ConflictingAlternatives,
raw: None,
});
}
if let Some(reference) = &self.referenced_amount {
if self.original_amount_source != self.referenced_amount_source {
let source = self
.referenced_amount_source
.map_or("referenced_amount", crate::camt::OriginalAmountSource::path);
result.extend(reference.diagnostics(&format!("{path}/{source}"), None));
}
if self
.original_amount_ct
.zip(reference.ct)
.is_some_and(|(primary, reference)| primary != reference)
|| self
.original_currency
.as_deref()
.zip(reference.currency.as_deref())
.is_some_and(|(primary, reference)| primary != reference)
{
result.push(crate::camt::FinancialDiagnostic {
path: path.to_owned(),
issue: crate::camt::FinancialIssue::ConflictingAlternatives,
raw: None,
});
}
}
if let Some(equivalent) = &self.referenced_equivalent_amount {
result.extend(equivalent.diagnostics(&format!("{reference_path}/EqvtAmt/Amt"), None));
result.extend(crate::camt::currency_diagnostics(
&format!("{reference_path}/EqvtAmt/CcyOfTrf"),
self.referenced_transfer_currency.as_deref(),
));
if self.referenced_amount.is_some() {
result.push(crate::camt::FinancialDiagnostic {
path: reference_path,
issue: crate::camt::FinancialIssue::ConflictingAlternatives,
raw: None,
});
}
}
result
}
#[must_use]
pub fn is_accepted(&self) -> bool {
self.status
.as_ref()
.is_some_and(CancellationStatus::is_accepted)
}
#[must_use]
pub fn is_rejected(&self) -> bool {
self.status
.as_ref()
.is_some_and(CancellationStatus::is_rejected)
}
#[must_use]
pub fn original_execution_date(&self) -> Option<IsoDate> {
IsoDate::parse_date_part(self.original_execution_date_raw.as_deref()?).ok()
}
#[must_use]
pub fn original_collection_date(&self) -> Option<IsoDate> {
IsoDate::parse_date_part(self.original_collection_date_raw.as_deref()?).ok()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PaymentInfoCancellationStatus {
pub original_cancellation_id: Option<String>,
pub original_payment_info_id: Option<String>,
pub status: Option<CancellationStatus>,
pub reason_codes: Vec<RejectionReason>,
pub additional_info: Vec<String>,
pub status_counts: Vec<CancellationCount>,
pub transactions: Vec<TransactionCancellationStatus>,
}
impl PaymentInfoCancellationStatus {
#[must_use]
pub fn has_rejections(&self) -> bool {
self.status
.as_ref()
.is_some_and(CancellationStatus::is_rejected)
|| self
.transactions
.iter()
.any(TransactionCancellationStatus::is_rejected)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GroupCancellationStatus {
pub original_cancellation_id: Option<String>,
pub original_message_id: Option<String>,
pub original_message_name_id: Option<String>,
pub original_created_at_raw: Option<String>,
pub original_number_of_transactions: Option<u64>,
pub original_control_sum_ct: Option<i64>,
pub original_control_sum_raw: Option<String>,
pub original_number_of_transactions_raw: Option<String>,
pub status: Option<CancellationStatus>,
pub reason_codes: Vec<RejectionReason>,
pub additional_info: Vec<String>,
pub status_counts: Vec<CancellationCount>,
pub payment_infos: Vec<PaymentInfoCancellationStatus>,
pub transactions: Vec<TransactionCancellationStatus>,
}
impl GroupCancellationStatus {
pub fn all_transactions(&self) -> impl Iterator<Item = &TransactionCancellationStatus> {
self.payment_infos
.iter()
.flat_map(|p| p.transactions.iter())
.chain(self.transactions.iter())
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Camt029Document {
pub assignment_id: String,
pub created_at_raw: String,
pub assigner_bic: Option<String>,
pub assigner_name: Option<String>,
pub resolved_case_id: Option<String>,
pub outcome: Option<ResolutionOutcome>,
pub namespace: Option<String>,
pub groups: Vec<GroupCancellationStatus>,
}
impl Camt029Document {
#[must_use]
pub fn financial_diagnostics(&self) -> Vec<crate::camt::FinancialDiagnostic> {
let mut result = Vec::new();
for (index, group) in self.groups.iter().enumerate() {
let path = format!("CxlDtls[{index}]");
result.extend(crate::camt::optional_count_diagnostics(
&format!("{path}/OrgnlGrpInfAndSts/OrgnlNbOfTxs"),
group.original_number_of_transactions_raw.as_deref(),
group.original_number_of_transactions,
));
result.extend(crate::camt::optional_decimal_diagnostics(
&format!("{path}/OrgnlGrpInfAndSts/OrgnlCtrlSum"),
group.original_control_sum_raw.as_deref(),
group.original_control_sum_ct,
));
for (index, count) in group.status_counts.iter().enumerate() {
result.extend(crate::camt::optional_count_diagnostics(
&format!("{path}/OrgnlGrpInfAndSts/NbOfTxsPerCxlSts[{index}]/DtldNbOfTxs"),
count.count_raw.as_deref(),
count.count,
));
result.extend(crate::camt::optional_decimal_diagnostics(
&format!("{path}/OrgnlGrpInfAndSts/NbOfTxsPerCxlSts[{index}]/DtldCtrlSum"),
count.control_sum_raw.as_deref(),
count.control_sum_ct,
));
}
for (index, transaction) in group.transactions.iter().enumerate() {
result.extend(
transaction.financial_diagnostics(&format!("{path}/TxInfAndSts[{index}]")),
);
}
for (index, info) in group.payment_infos.iter().enumerate() {
let path = format!("{path}/OrgnlPmtInfAndSts[{index}]");
for (index, count) in info.status_counts.iter().enumerate() {
result.extend(crate::camt::optional_count_diagnostics(
&format!("{path}/NbOfTxsPerCxlSts[{index}]/DtldNbOfTxs"),
count.count_raw.as_deref(),
count.count,
));
result.extend(crate::camt::optional_decimal_diagnostics(
&format!("{path}/NbOfTxsPerCxlSts[{index}]/DtldCtrlSum"),
count.control_sum_raw.as_deref(),
count.control_sum_ct,
));
}
for (index, transaction) in info.transactions.iter().enumerate() {
result.extend(
transaction.financial_diagnostics(&format!("{path}/TxInfAndSts[{index}]")),
);
}
}
}
result
}
#[must_use]
pub fn created_at(&self) -> Option<crate::IsoDateTime> {
crate::IsoDateTime::parse(&self.created_at_raw).ok()
}
}
impl Camt029Document {
pub fn transactions(&self) -> impl Iterator<Item = &TransactionCancellationStatus> {
self.groups
.iter()
.flat_map(GroupCancellationStatus::all_transactions)
}
#[must_use]
pub fn is_accepted(&self) -> bool {
if let Some(outcome) = &self.outcome {
return outcome.is_accepted();
}
let mut any = false;
for status in self.statuses() {
any = true;
if !status.is_accepted() {
return false;
}
}
any
}
#[must_use]
pub fn has_rejections(&self) -> bool {
self.outcome
.as_ref()
.is_some_and(ResolutionOutcome::is_rejected)
|| self.statuses().any(CancellationStatus::is_rejected)
}
#[must_use]
pub fn is_final(&self) -> bool {
self.outcome
.as_ref()
.is_none_or(ResolutionOutcome::is_final)
&& self.statuses().all(CancellationStatus::is_final)
}
#[must_use]
pub fn rejection_reasons(&self) -> Vec<&RejectionReason> {
let mut out = Vec::new();
for g in &self.groups {
out.extend(&g.reason_codes);
for p in &g.payment_infos {
out.extend(&p.reason_codes);
for t in &p.transactions {
out.extend(&t.reason_codes);
}
}
for t in &g.transactions {
out.extend(&t.reason_codes);
}
}
out
}
fn statuses(&self) -> impl Iterator<Item = &CancellationStatus> {
self.groups.iter().flat_map(|g| {
g.status
.iter()
.chain(g.payment_infos.iter().flat_map(|p| p.status.iter()))
.chain(g.all_transactions().filter_map(|t| t.status.as_ref()))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum Camt029ParseError {
#[error(transparent)]
Xml(#[from] XmlError),
#[error("not a camt.029 document: root element <RsltnOfInvstgtn> not found")]
NotCamt029,
#[error("camt.029 is missing mandatory element <{tag}>")]
MissingElement {
tag: &'static str,
},
}
pub fn parse_camt029(xml: &str) -> Result<Camt029Document, Camt029ParseError> {
parse_camt029_with_limits(xml, crate::ParseLimits::default())
}
pub fn parse_camt029_with_limits(
xml: &str,
limits: crate::ParseLimits,
) -> Result<Camt029Document, Camt029ParseError> {
let doc = Document::parse_with_limits(xml, limits)?;
let namespace = doc.namespace.clone();
let root = doc
.root
.child("RsltnOfInvstgtn")
.ok_or(Camt029ParseError::NotCamt029)?;
doc.message("camt.029", "RsltnOfInvstgtn", &["001.06"])?;
let assignment = root
.child("Assgnmt")
.ok_or(Camt029ParseError::MissingElement { tag: "Assgnmt" })?;
let assignment_id = assignment
.text_of("Id")
.ok_or(Camt029ParseError::MissingElement { tag: "Assgnmt/Id" })?
.to_owned();
let assigner = assignment.child("Assgnr");
Ok(Camt029Document {
assignment_id,
created_at_raw: assignment.text_of("CreDtTm").unwrap_or_default().to_owned(),
assigner_bic: assigner
.and_then(|a| a.child("Agt"))
.and_then(crate::camt::agent_bic)
.map(str::to_owned),
assigner_name: assigner
.and_then(|a| a.text_at(&["Pty", "Nm"]))
.map(str::to_owned),
resolved_case_id: root.text_at(&["RslvdCase", "Id"]).map(str::to_owned),
outcome: root
.path(&["Sts", "Conf"])
.map(|n| ResolutionOutcome::from_code(&n.text))
.or_else(|| {
root.child("Sts")
.filter(|n| !n.text.is_empty())
.map(|n| ResolutionOutcome::from_code(&n.text))
}),
namespace,
groups: root.children_named("CxlDtls").map(parse_group).collect(),
})
}
fn parse_reasons(block: &Node) -> (Vec<RejectionReason>, Vec<String>) {
let mut codes = Vec::new();
let mut info = Vec::new();
for r in block.children_named("CxlStsRsnInf") {
if let Some(code) = r.child("Rsn").and_then(Node::code) {
codes.push(RejectionReason::from_code(code));
}
info.extend(
r.children_named("AddtlInf")
.filter(|n| !n.text.is_empty())
.map(|n| n.text.clone()),
);
}
(codes, info)
}
fn parse_counts(block: &Node) -> Vec<CancellationCount> {
block
.children_named("NbOfTxsPerCxlSts")
.map(|n| CancellationCount {
status: n.text_of("DtldSts").map(CancellationStatus::from_code),
status_raw: n.raw_text_of("DtldSts").map(str::to_owned),
count: n.text_of("DtldNbOfTxs").and_then(|v| v.parse().ok()),
count_raw: n.raw_text_of("DtldNbOfTxs").map(str::to_owned),
control_sum_raw: n.raw_text_of("DtldCtrlSum").map(str::to_owned),
control_sum_ct: n
.text_of("DtldCtrlSum")
.and_then(|v| crate::ct_from_eur_str(v).ok()),
})
.collect()
}
fn parse_group(block: &Node) -> GroupCancellationStatus {
let header = block.child("OrgnlGrpInfAndSts");
let (reason_codes, additional_info) = header.map_or_else(Default::default, parse_reasons);
let control_sum = header.and_then(|h| h.raw_text_of("OrgnlCtrlSum"));
GroupCancellationStatus {
original_cancellation_id: header
.and_then(|h| h.text_of("OrgnlGrpCxlId"))
.map(str::to_owned),
original_message_id: header
.and_then(|h| h.text_of("OrgnlMsgId"))
.map(str::to_owned),
original_message_name_id: header
.and_then(|h| h.text_of("OrgnlMsgNmId"))
.map(str::to_owned),
original_created_at_raw: header
.and_then(|h| h.text_of("OrgnlCreDtTm"))
.map(str::to_owned),
original_number_of_transactions: header
.and_then(|h| h.text_of("OrgnlNbOfTxs"))
.and_then(|v| v.parse().ok()),
original_control_sum_ct: control_sum.and_then(|v| crate::ct_from_eur_str(v).ok()),
original_control_sum_raw: control_sum.map(str::to_owned),
original_number_of_transactions_raw: header
.and_then(|node| node.raw_text_of("OrgnlNbOfTxs"))
.map(str::to_owned),
status: header
.and_then(|h| h.text_of("GrpCxlSts"))
.map(CancellationStatus::from_code),
reason_codes,
additional_info,
status_counts: header.map(parse_counts).unwrap_or_default(),
payment_infos: block
.children_named("OrgnlPmtInfAndSts")
.map(parse_payment_info)
.collect(),
transactions: block
.children_named("TxInfAndSts")
.map(parse_transaction)
.collect(),
}
}
fn parse_payment_info(block: &Node) -> PaymentInfoCancellationStatus {
let (reason_codes, additional_info) = parse_reasons(block);
PaymentInfoCancellationStatus {
original_cancellation_id: block.text_of("OrgnlPmtInfCxlId").map(str::to_owned),
original_payment_info_id: block.text_of("OrgnlPmtInfId").map(str::to_owned),
status: block
.text_of("PmtInfCxlSts")
.map(CancellationStatus::from_code),
reason_codes,
additional_info,
status_counts: parse_counts(block),
transactions: block
.children_named("TxInfAndSts")
.map(parse_transaction)
.collect(),
}
}
fn parse_transaction(tx: &Node) -> TransactionCancellationStatus {
let (reason_codes, additional_info) = parse_reasons(tx);
let orig_ref = tx.child("OrgnlTxRef");
let canonical_parent = orig_ref
.and_then(|reference| reference.child("Amt"))
.filter(|parent| parent.child("InstdAmt").is_some());
let referenced_amount = orig_ref.and_then(|reference| {
let parent = canonical_parent.unwrap_or(reference);
parent
.child("InstdAmt")
.map(|_| ReportedAmount::parse(parent, "InstdAmt"))
});
let referenced_amount_source = referenced_amount.as_ref().map(|_| {
if canonical_parent.is_some() {
crate::camt::OriginalAmountSource::TransactionReference
} else {
crate::camt::OriginalAmountSource::LegacyTransactionReference
}
});
let legacy_referenced_amount = orig_ref
.filter(|reference| canonical_parent.is_some() && reference.child("InstdAmt").is_some())
.map(|reference| ReportedAmount::parse(reference, "InstdAmt"));
let original_amount_source = if tx.child("OrgnlInstdAmt").is_some() {
Some(crate::camt::OriginalAmountSource::OriginalInstructed)
} else {
referenced_amount_source
};
let amount = if tx.child("OrgnlInstdAmt").is_some() {
ReportedAmount::parse(tx, "OrgnlInstdAmt")
} else {
referenced_amount.clone().unwrap_or_default()
};
let equivalent = orig_ref.and_then(|reference| reference.path(&["Amt", "EqvtAmt"]));
let date_of = |tag: &str| {
tx.text_of(tag)
.or_else(|| orig_ref.and_then(|r| r.text_of(tag.trim_start_matches("Orgnl"))))
.map(str::to_owned)
};
TransactionCancellationStatus {
cancellation_status_id: tx.text_of("CxlStsId").map(str::to_owned),
original_end_to_end_id: tx.text_of("OrgnlEndToEndId").map(str::to_owned),
original_instruction_id: tx.text_of("OrgnlInstrId").map(str::to_owned),
status: tx.text_of("TxCxlSts").map(CancellationStatus::from_code),
reason_codes,
additional_info,
original_amount_ct: amount.ct,
original_currency: amount.currency,
original_amount_raw: amount.amount_raw,
original_amount_source,
referenced_amount,
referenced_amount_source,
legacy_referenced_amount,
original_settlement_amount: orig_ref
.filter(|reference| reference.child("IntrBkSttlmAmt").is_some())
.map(|reference| ReportedAmount::parse(reference, "IntrBkSttlmAmt")),
referenced_equivalent_amount: equivalent.map(|node| ReportedAmount::parse(node, "Amt")),
referenced_transfer_currency: equivalent
.and_then(|node| node.raw_text_of("CcyOfTrf"))
.map(str::to_owned),
original_execution_date_raw: date_of("OrgnlReqdExctnDt"),
original_collection_date_raw: date_of("OrgnlReqdColltnDt"),
}
}
#[cfg(test)]
mod tests {
use super::*;
const ACCEPTED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.029.001.06">
<RsltnOfInvstgtn>
<Assgnmt><Id>RES-1</Id>
<Assgnr><Agt><FinInstnId><BICFI>COBADEFFXXX</BICFI></FinInstnId></Agt></Assgnr>
<Assgne><Pty><Nm>Stadtwerke GmbH</Nm></Pty></Assgne>
<CreDtTm>2026-07-15T14:02:00</CreDtTm></Assgnmt>
<RslvdCase><Id>CXL-1</Id><Cretr><Pty><Nm>Stadtwerke GmbH</Nm></Pty></Cretr></RslvdCase>
<Sts><Conf>CNCL</Conf></Sts>
<CxlDtls>
<OrgnlGrpInfAndSts>
<OrgnlMsgId>DD-2026-07-001</OrgnlMsgId>
<OrgnlMsgNmId>pain.008.001.08</OrgnlMsgNmId>
<OrgnlCreDtTm>2026-07-15T09:00:00</OrgnlCreDtTm>
<OrgnlNbOfTxs>3</OrgnlNbOfTxs>
<OrgnlCtrlSum>225.00</OrgnlCtrlSum>
</OrgnlGrpInfAndSts>
<OrgnlPmtInfAndSts>
<OrgnlPmtInfCxlId>PC-1</OrgnlPmtInfCxlId>
<OrgnlPmtInfId>PMT-A</OrgnlPmtInfId>
<NbOfTxsPerCxlSts><DtldNbOfTxs>1</DtldNbOfTxs><DtldSts>ACCR</DtldSts>
<DtldCtrlSum>75.00</DtldCtrlSum></NbOfTxsPerCxlSts>
<TxInfAndSts>
<CxlStsId>CS-1</CxlStsId>
<OrgnlEndToEndId>E2E-1</OrgnlEndToEndId>
<TxCxlSts>ACCR</TxCxlSts>
<OrgnlInstdAmt Ccy="EUR">75.00</OrgnlInstdAmt>
<OrgnlReqdColltnDt>2026-07-20</OrgnlReqdColltnDt>
</TxInfAndSts>
</OrgnlPmtInfAndSts>
</CxlDtls>
</RsltnOfInvstgtn>
</Document>"#;
const REFUSED_WHOLESALE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.029.001.06">
<RsltnOfInvstgtn>
<Assgnmt><Id>RES-2</Id>
<Assgnr><Agt><FinInstnId><BICFI>COBADEFFXXX</BICFI></FinInstnId></Agt></Assgnr>
<Assgne><Pty><Nm>Stadtwerke GmbH</Nm></Pty></Assgne>
<CreDtTm>2026-07-15T14:02:00</CreDtTm></Assgnmt>
<RslvdCase><Id>CXL-2</Id><Cretr><Pty><Nm>Stadtwerke GmbH</Nm></Pty></Cretr></RslvdCase>
<Sts><Conf>RJCR</Conf></Sts>
<CxlDtls>
<OrgnlGrpInfAndSts>
<OrgnlMsgId>DD-2026-07-001</OrgnlMsgId>
<OrgnlMsgNmId>pain.008.001.08</OrgnlMsgNmId>
<GrpCxlSts>RJCR</GrpCxlSts>
<CxlStsRsnInf><Rsn><Cd>ARDT</Cd></Rsn>
<AddtlInf>Bereits ausgefuehrt und zurueckgegeben</AddtlInf>
<AddtlInf>Bitte pain.007 verwenden</AddtlInf></CxlStsRsnInf>
</OrgnlGrpInfAndSts>
</CxlDtls>
</RsltnOfInvstgtn>
</Document>"#;
#[test]
fn an_accepted_recall_reads_back_the_keys_it_was_sent_with() {
let doc = parse_camt029(ACCEPTED).unwrap();
assert_eq!(doc.assignment_id, "RES-1");
assert_eq!(doc.assigner_bic.as_deref(), Some("COBADEFFXXX"));
assert_eq!(doc.resolved_case_id.as_deref(), Some("CXL-1"));
assert_eq!(doc.outcome, Some(ResolutionOutcome::Cancelled));
assert!(doc.is_accepted());
assert!(doc.is_final());
assert!(!doc.has_rejections());
assert_eq!(doc.rejection_reasons().len(), 0);
let g = &doc.groups[0];
assert_eq!(g.original_message_id.as_deref(), Some("DD-2026-07-001"));
assert_eq!(g.original_number_of_transactions, Some(3));
assert_eq!(g.original_control_sum_ct, Some(22_500));
let p = &g.payment_infos[0];
assert_eq!(p.original_payment_info_id.as_deref(), Some("PMT-A"));
assert_eq!(p.original_cancellation_id.as_deref(), Some("PC-1"));
assert_eq!(
p.status_counts[0].status,
Some(CancellationStatus::Accepted)
);
assert_eq!(p.status_counts[0].count, Some(1));
assert_eq!(p.status_counts[0].control_sum_ct, Some(7_500));
let t = &p.transactions[0];
assert_eq!(t.original_end_to_end_id.as_deref(), Some("E2E-1"));
assert_eq!(t.status, Some(CancellationStatus::Accepted));
assert_eq!(t.original_amount_ct, Some(7_500));
assert_eq!(t.original_currency.as_deref(), Some("EUR"));
assert_eq!(
t.original_collection_date(),
Some(IsoDate::new(2026, 7, 20).unwrap())
);
assert!(t.is_accepted());
}
#[test]
fn a_whole_file_refusal_still_explains_itself() {
let doc = parse_camt029(REFUSED_WHOLESALE).unwrap();
assert!(!doc.is_accepted());
assert!(doc.has_rejections());
assert!(doc.is_final());
assert_eq!(doc.transactions().count(), 0);
let reasons = doc.rejection_reasons();
assert_eq!(reasons, [&RejectionReason::Ardt]);
assert!(reasons[0].is_already_returned());
assert_eq!(
doc.groups[0].additional_info,
[
"Bereits ausgefuehrt und zurueckgegeben",
"Bitte pain.007 verwenden"
]
);
}
#[test]
fn already_returned_reason_matches_the_pinned_schema() {
let schema = include_str!("../tests/xsd/camt.029.001.06.xsd");
assert!(
schema.contains(
"Cancellation not accepted as the transaction has already been returned."
)
);
assert!(RejectionReason::from_code("ARDT").is_already_returned());
for reason in [
RejectionReason::Legl,
RejectionReason::Agnt,
RejectionReason::Cust,
RejectionReason::Noas,
RejectionReason::Noor,
RejectionReason::Ac04,
RejectionReason::Am04,
RejectionReason::Other("UNKNOWN".into()),
] {
assert!(!reason.is_already_returned(), "{reason}");
}
}
#[test]
fn pending_is_neither_outcome() {
let xml = ACCEPTED
.replace("<Conf>CNCL</Conf>", "<Conf>PDCR</Conf>")
.replace("<TxCxlSts>ACCR</TxCxlSts>", "<TxCxlSts>PDCR</TxCxlSts>");
let doc = parse_camt029(&xml).unwrap();
assert_eq!(doc.outcome, Some(ResolutionOutcome::PendingCancellation));
assert!(!doc.is_accepted());
assert!(!doc.has_rejections());
assert!(!doc.is_final(), "nothing has been decided yet");
assert_eq!(
doc.transactions().next().unwrap().status,
Some(CancellationStatus::Pending)
);
}
#[test]
fn an_empty_document_is_not_an_acceptance() {
let xml = r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.029.001.06">
<RsltnOfInvstgtn><Assgnmt><Id>R</Id><CreDtTm>2026-07-15T14:00:00</CreDtTm></Assgnmt>
</RsltnOfInvstgtn></Document>"#;
let doc = parse_camt029(xml).unwrap();
assert_eq!(doc.outcome, None);
assert!(!doc.is_accepted());
assert_eq!(doc.groups.len(), 0);
}
#[test]
fn transactions_reported_outside_a_payment_info_block_are_not_lost() {
let xml = r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.029.001.06">
<RsltnOfInvstgtn>
<Assgnmt><Id>R</Id><Assgnr><Pty><Nm>Bank</Nm></Pty></Assgnr><Assgne><Pty><Nm>Acme GmbH</Nm></Pty></Assgne><CreDtTm>2026-07-15T14:00:00</CreDtTm></Assgnmt>
<Sts><Conf>PECR</Conf></Sts>
<CxlDtls>
<TxInfAndSts><OrgnlEndToEndId>E2E-1</OrgnlEndToEndId>
<TxCxlSts>ACCR</TxCxlSts></TxInfAndSts>
<TxInfAndSts><OrgnlEndToEndId>E2E-2</OrgnlEndToEndId><TxCxlSts>RJCR</TxCxlSts>
<CxlStsRsnInf><Rsn><Cd>CUST</Cd></Rsn></CxlStsRsnInf></TxInfAndSts>
</CxlDtls>
</RsltnOfInvstgtn></Document>"#;
let doc = parse_camt029(xml).unwrap();
let refs: Vec<_> = doc
.transactions()
.filter_map(|t| t.original_end_to_end_id.as_deref())
.collect();
assert_eq!(refs, ["E2E-1", "E2E-2"]);
assert_eq!(doc.outcome, Some(ResolutionOutcome::PartiallyCancelled));
assert!(doc.has_rejections());
assert!(!doc.is_accepted());
assert_eq!(doc.rejection_reasons(), [&RejectionReason::Cust]);
}
#[test]
fn a_prefixed_namespace_reads_identically() {
let prefixed = ACCEPTED
.replace("<Document xmlns=", "<ns2:Document xmlns:ns2=")
.replace("</Document>", "</ns2:Document>");
let mut out = String::new();
for part in prefixed.split('<') {
if part.is_empty() {
continue;
}
out.push('<');
if part.starts_with("ns2:") || part.starts_with("/ns2:") || part.starts_with('?') {
out.push_str(part);
} else if let Some(rest) = part.strip_prefix('/') {
out.push_str("/ns2:");
out.push_str(rest);
} else {
out.push_str("ns2:");
out.push_str(part);
}
}
let doc = parse_camt029(&out).unwrap();
assert_eq!(doc.resolved_case_id.as_deref(), Some("CXL-1"));
assert!(doc.is_accepted());
}
#[test]
fn a_document_that_is_not_camt029_is_refused() {
let xml = r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.08">
<BkToCstmrStmt/></Document>"#;
assert!(matches!(
parse_camt029(xml),
Err(Camt029ParseError::NotCamt029)
));
assert!(matches!(
parse_camt029(
"<Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:camt.029.001.06\"><RsltnOfInvstgtn/></Document>"
),
Err(Camt029ParseError::MissingElement { tag: "Assgnmt" })
));
}
#[test]
fn status_and_outcome_codes_round_trip() {
for (code, status) in [
("ACCR", CancellationStatus::Accepted),
("RJCR", CancellationStatus::Rejected),
("PDCR", CancellationStatus::Pending),
("PACR", CancellationStatus::PartiallyAccepted),
] {
assert_eq!(CancellationStatus::from_code(code), status);
assert_eq!(status.as_code(), code);
}
assert!(CancellationStatus::PartiallyAccepted.is_final());
assert!(!CancellationStatus::PartiallyAccepted.is_accepted());
assert!(!CancellationStatus::Other("XX".to_owned()).is_final());
assert_eq!(
ResolutionOutcome::from_code("cncl"),
ResolutionOutcome::Other("cncl".to_owned())
);
assert_eq!(
RejectionReason::from_code("ardt"),
RejectionReason::Other("ardt".to_owned())
);
}
}