use crate::camt::amount_of;
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().to_ascii_uppercase().as_str() {
"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().to_ascii_uppercase().as_str() {
"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().to_ascii_uppercase().as_str() {
"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_too_late(&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: CancellationStatus,
pub count: Option<u64>,
pub control_sum_ct: Option<i64>,
}
#[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_execution_date_raw: Option<String>,
pub original_collection_date_raw: Option<String>,
}
impl TransactionCancellationStatus {
#[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 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: 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 {
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> {
let doc = Document::parse(xml)?;
let namespace = doc.namespace;
let root = doc
.root
.child("RsltnOfInvstgtn")
.ok_or(Camt029ParseError::NotCamt029)?;
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: 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_or_else(
|| CancellationStatus::Other(String::new()),
CancellationStatus::from_code,
),
count: n.text_of("DtldNbOfTxs").and_then(|v| v.parse().ok()),
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.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()),
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 amount = amount_of(tx, "OrgnlInstdAmt").or_else(|| {
tx.child("OrgnlTxRef").and_then(|r| {
r.child("Amt")
.map_or_else(|| amount_of(r, "InstdAmt"), |a| amount_of(a, "InstdAmt"))
})
});
let orig_ref = tx.child("OrgnlTxRef");
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.as_ref().map(|(ct, _)| *ct),
original_currency: amount.map(|(_, ccy)| ccy),
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!(doc.rejection_reasons().is_empty());
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, 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_too_late(), "ARDT means pain.007 is the route");
assert_eq!(
doc.groups[0].additional_info,
[
"Bereits ausgefuehrt und zurueckgegeben",
"Bitte pain.007 verwenden"
]
);
}
#[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!(doc.groups.is_empty());
}
#[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><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><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::Cancelled
);
assert_eq!(RejectionReason::from_code("ardt"), RejectionReason::Ardt);
}
}