use crate::bic::BicPattern;
use crate::date::IsoDate;
use crate::party::Party;
use crate::validate::{
BuildError, CharsetPolicy, Locate, Location, MAX_ADDITIONAL_INFO_LEN, MAX_ID_LEN,
ValidationError, WriteError, check_amount, check_id, check_text,
};
use crate::xml_util::{write_escaped, write_eur};
use crate::{Bic, IsoDateTime, ct_to_eur_str};
pub const NAMESPACE: &str = "urn:iso:std:iso:20022:tech:xsd:camt.055.001.05";
pub const MESSAGE_ID: &str = "camt.055.001.05";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CancellationReason {
Dupl,
Agnt,
Curr,
Cust,
Upay,
Cuta,
Tech,
Frad,
Other(String),
}
impl CancellationReason {
#[must_use]
pub fn as_code(&self) -> &str {
match self {
Self::Dupl => "DUPL",
Self::Agnt => "AGNT",
Self::Curr => "CURR",
Self::Cust => "CUST",
Self::Upay => "UPAY",
Self::Cuta => "CUTA",
Self::Tech => "TECH",
Self::Frad => "FRAD",
Self::Other(s) => s,
}
}
#[must_use]
pub const fn is_iso_code(&self) -> bool {
!matches!(self, Self::Other(_))
}
pub fn validate(&self, field: &'static str) -> Result<(), ValidationError> {
let code = self.as_code();
check_text(field, code, MAX_ID_LEN)?;
match code.chars().find(|c| !c.is_ascii_alphanumeric()) {
Some(ch) => Err(ValidationError::InvalidCharacter { field, ch }),
None => Ok(()),
}
}
fn write_xml<W: std::fmt::Write>(&self, w: &mut W) -> std::fmt::Result {
if self.is_iso_code() {
write!(w, "<Rsn><Cd>{}</Cd></Rsn>", self.as_code())
} else {
w.write_str("<Rsn><Prtry>")?;
write_escaped(w, self.as_code())?;
w.write_str("</Prtry></Rsn>")
}
}
}
impl std::fmt::Display for CancellationReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
impl std::str::FromStr for CancellationReason {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.trim().to_ascii_uppercase().as_str() {
"DUPL" | "DUPLICATE" => Self::Dupl,
"AGNT" => Self::Agnt,
"CURR" => Self::Curr,
"CUST" => Self::Cust,
"UPAY" => Self::Upay,
"CUTA" => Self::Cuta,
"TECH" => Self::Tech,
"FRAD" | "FRAUD" => Self::Frad,
other => Self::Other(other.to_owned()),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CaseParty {
Party(Party),
Agent(Bic),
}
impl CaseParty {
fn validate(
&self,
field: &'static str,
charset: CharsetPolicy,
pattern: BicPattern,
) -> Result<(), ValidationError> {
match self {
Self::Party(p) => p.validate(field, charset),
Self::Agent(bic) if bic.fits(pattern) => Ok(()),
Self::Agent(bic) => Err(ValidationError::SchemaPattern {
field,
value: bic.as_str().to_owned(),
schema: MESSAGE_ID,
expected: pattern.as_xsd_pattern(),
}),
}
}
fn write_xml<W: std::fmt::Write>(
&self,
w: &mut W,
tag: &str,
charset: CharsetPolicy,
) -> std::fmt::Result {
match self {
Self::Party(p) => {
write!(w, "<{tag}>")?;
p.write_xml_inline(w, "Pty", charset)?;
write!(w, "</{tag}>")
}
Self::Agent(bic) => write!(
w,
"<{tag}><Agt><FinInstnId><BICFI>{}</BICFI></FinInstnId></Agt></{tag}>",
bic.as_str()
),
}
}
}
impl From<Party> for CaseParty {
fn from(p: Party) -> Self {
Self::Party(p)
}
}
impl From<&str> for CaseParty {
fn from(name: &str) -> Self {
Self::Party(Party::new(name))
}
}
impl From<String> for CaseParty {
fn from(name: String) -> Self {
Self::Party(Party::new(name))
}
}
impl From<Bic> for CaseParty {
fn from(bic: Bic) -> Self {
Self::Agent(bic)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OriginalMessage {
pub message_id: String,
pub message_name_id: String,
pub created_at: Option<IsoDateTime>,
pub number_of_transactions: Option<u64>,
pub control_sum_ct: Option<i64>,
}
impl OriginalMessage {
pub fn new(message_id: impl Into<String>, message_name_id: impl Into<String>) -> Self {
Self {
message_id: message_id.into(),
message_name_id: message_name_id.into(),
created_at: None,
number_of_transactions: None,
control_sum_ct: None,
}
}
#[must_use]
pub fn from_credit_transfer(builder: &crate::Pain001Builder) -> Self {
Self {
message_id: builder.message_id().to_owned(),
message_name_id: builder.schema_version().message_id().to_owned(),
created_at: builder.creation_timestamp(),
number_of_transactions: Some(builder.entry_count() as u64),
control_sum_ct: Some(builder.total_ct()),
}
}
#[must_use]
pub fn from_direct_debit(builder: &crate::Pain008Builder) -> Self {
Self {
message_id: builder.message_id().to_owned(),
message_name_id: builder.schema_version().message_id().to_owned(),
created_at: builder.creation_timestamp(),
number_of_transactions: Some(builder.entry_count() as u64),
control_sum_ct: Some(builder.total_ct()),
}
}
#[must_use]
pub fn created_at(mut self, timestamp: IsoDateTime) -> Self {
self.created_at = Some(timestamp);
self
}
#[must_use]
pub fn totals(mut self, transactions: u64, control_sum_ct: i64) -> Self {
self.number_of_transactions = Some(transactions);
self.control_sum_ct = Some(control_sum_ct);
self
}
fn validate(&self) -> Result<(), ValidationError> {
check_id("OrgnlGrpInf/OrgnlMsgId", &self.message_id)?;
check_id("OrgnlGrpInf/OrgnlMsgNmId", &self.message_name_id)?;
if let Some(count) = self.number_of_transactions {
check_text("OrgnlGrpInfAndCxl/NbOfTxs", &count.to_string(), 15)?;
}
if let Some(amount) = self.control_sum_ct {
let scaled = if amount % 100 == 0 {
amount / 100
} else if amount % 10 == 0 {
amount / 10
} else {
amount
};
check_text(
"OrgnlGrpInfAndCxl/CtrlSum",
&scaled.unsigned_abs().to_string(),
18,
)?;
}
Ok(())
}
fn write_reference<W: std::fmt::Write>(&self, w: &mut W) -> std::fmt::Result {
w.write_str("<OrgnlMsgId>")?;
write_escaped(w, &self.message_id)?;
w.write_str("</OrgnlMsgId><OrgnlMsgNmId>")?;
write_escaped(w, &self.message_name_id)?;
w.write_str("</OrgnlMsgNmId>")?;
if let Some(t) = self.created_at {
write!(w, "<OrgnlCreDtTm>{t}</OrgnlCreDtTm>")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CancellationEntry {
pub original_end_to_end_id: String,
pub reason: CancellationReason,
pub cancellation_id: Option<String>,
pub original_instruction_id: Option<String>,
pub original_amount_ct: Option<i64>,
pub original_execution_date: Option<IsoDate>,
pub original_collection_date: Option<IsoDate>,
pub additional_info: Option<String>,
}
impl CancellationEntry {
pub fn new(original_end_to_end_id: impl Into<String>, reason: CancellationReason) -> Self {
Self {
original_end_to_end_id: original_end_to_end_id.into(),
reason,
cancellation_id: None,
original_instruction_id: None,
original_amount_ct: None,
original_execution_date: None,
original_collection_date: None,
additional_info: None,
}
}
#[must_use]
pub fn from_credit_transfer(
group: &crate::CreditTransferGroup,
entry: &crate::CreditTransferEntry,
reason: CancellationReason,
) -> Self {
Self {
original_amount_ct: Some(entry.amount_ct),
original_execution_date: Some(group.requested_execution_date()),
..Self::new(entry.end_to_end_id.clone(), reason)
}
}
#[must_use]
pub fn from_direct_debit(
group: &crate::DirectDebitGroup,
entry: &crate::DirectDebitEntry,
reason: CancellationReason,
) -> Self {
Self {
original_amount_ct: Some(entry.amount_ct),
original_collection_date: Some(group.requested_collection_date()),
..Self::new(entry.end_to_end_id.clone(), reason)
}
}
#[must_use]
pub fn cancellation_id(mut self, id: impl Into<String>) -> Self {
self.cancellation_id = Some(id.into());
self
}
#[must_use]
pub fn original_instruction_id(mut self, id: impl Into<String>) -> Self {
self.original_instruction_id = Some(id.into());
self
}
#[must_use]
pub fn original_amount(mut self, amount_ct: i64) -> Self {
self.original_amount_ct = Some(amount_ct);
self
}
#[must_use]
pub fn additional_info(mut self, text: impl Into<String>) -> Self {
self.additional_info = Some(text.into());
self
}
fn validate(&self, charset: CharsetPolicy) -> Result<(), ValidationError> {
check_id("TxInf/OrgnlEndToEndId", &self.original_end_to_end_id)?;
if let Some(id) = &self.cancellation_id {
check_id("TxInf/CxlId", id)?;
}
if let Some(id) = &self.original_instruction_id {
check_id("TxInf/OrgnlInstrId", id)?;
}
if let Some(amount) = self.original_amount_ct {
check_amount("TxInf/OrgnlInstdAmt", amount)?;
}
self.reason.validate("CxlRsnInf/Rsn")?;
if let Some(text) = &self.additional_info {
check_text(
"CxlRsnInf/AddtlInf",
&charset.apply("CxlRsnInf/AddtlInf", text)?,
MAX_ADDITIONAL_INFO_LEN,
)?;
}
Ok(())
}
fn write_xml<W: std::fmt::Write>(&self, w: &mut W, charset: CharsetPolicy) -> std::fmt::Result {
w.write_str(" <TxInf>")?;
if let Some(id) = &self.cancellation_id {
w.write_str("<CxlId>")?;
write_escaped(w, id)?;
w.write_str("</CxlId>")?;
}
if let Some(id) = &self.original_instruction_id {
w.write_str("<OrgnlInstrId>")?;
write_escaped(w, id)?;
w.write_str("</OrgnlInstrId>")?;
}
w.write_str("<OrgnlEndToEndId>")?;
write_escaped(w, &self.original_end_to_end_id)?;
w.write_str("</OrgnlEndToEndId>")?;
if let Some(amount) = self.original_amount_ct {
w.write_str("<OrgnlInstdAmt Ccy=\"EUR\">")?;
write_eur(w, amount)?;
w.write_str("</OrgnlInstdAmt>")?;
}
if let Some(d) = self.original_execution_date {
write!(w, "<OrgnlReqdExctnDt>{d}</OrgnlReqdExctnDt>")?;
}
if let Some(d) = self.original_collection_date {
write!(w, "<OrgnlReqdColltnDt>{d}</OrgnlReqdColltnDt>")?;
}
write_reason(w, &self.reason, self.additional_info.as_deref(), charset)?;
w.write_str("</TxInf>\n")
}
}
fn write_reason<W: std::fmt::Write>(
w: &mut W,
reason: &CancellationReason,
additional_info: Option<&str>,
charset: CharsetPolicy,
) -> std::fmt::Result {
w.write_str("<CxlRsnInf>")?;
reason.write_xml(w)?;
if let Some(text) = additional_info {
w.write_str("<AddtlInf>")?;
write_escaped(w, &charset.render(text))?;
w.write_str("</AddtlInf>")?;
}
w.write_str("</CxlRsnInf>")
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CancellationGroup {
original_payment_info_id: String,
payment_cancellation_id: Option<String>,
whole_group: Option<CancellationReason>,
additional_info: Option<String>,
entries: Vec<CancellationEntry>,
}
impl CancellationGroup {
pub fn new(original_payment_info_id: impl Into<String>) -> Self {
Self {
original_payment_info_id: original_payment_info_id.into(),
payment_cancellation_id: None,
whole_group: None,
additional_info: None,
entries: Vec::new(),
}
}
#[must_use]
pub fn cancel_whole_group(mut self, reason: CancellationReason) -> Self {
self.whole_group = Some(reason);
self
}
#[must_use]
pub fn payment_cancellation_id(mut self, id: impl Into<String>) -> Self {
self.payment_cancellation_id = Some(id.into());
self
}
#[must_use]
pub fn additional_info(mut self, text: impl Into<String>) -> Self {
self.additional_info = Some(text.into());
self
}
#[must_use]
pub fn add_entry(mut self, entry: CancellationEntry) -> Self {
self.entries.push(entry);
self
}
#[must_use]
pub fn add_entries(mut self, entries: impl IntoIterator<Item = CancellationEntry>) -> Self {
self.entries.extend(entries);
self
}
#[must_use]
pub fn original_payment_info_id(&self) -> &str {
&self.original_payment_info_id
}
#[must_use]
pub fn entry_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn total_ct(&self) -> Option<i64> {
self.entries
.iter()
.try_fold(0i64, |acc, e| acc.checked_add(e.original_amount_ct?))
}
}
#[derive(Debug, Clone)]
pub struct Camt055Builder {
assignment_id: String,
assigner: CaseParty,
assignee: CaseParty,
created_at: Option<IsoDateTime>,
case_id: Option<String>,
original: OriginalMessage,
whole_message: Option<CancellationReason>,
whole_message_info: Option<String>,
groups: Vec<CancellationGroup>,
charset: CharsetPolicy,
}
impl Camt055Builder {
#[allow(
clippy::similar_names,
reason = "`Assgnr` and `Assgne` are the schema's own element names"
)]
pub fn new(
assignment_id: impl Into<String>,
assigner: impl Into<CaseParty>,
assignee: impl Into<CaseParty>,
original: OriginalMessage,
) -> Self {
Self {
assignment_id: assignment_id.into(),
assigner: assigner.into(),
assignee: assignee.into(),
created_at: None,
case_id: None,
original,
whole_message: None,
whole_message_info: None,
groups: Vec::new(),
charset: CharsetPolicy::default(),
}
}
#[must_use]
pub fn created_at(mut self, timestamp: IsoDateTime) -> Self {
self.created_at = Some(timestamp);
self
}
#[must_use]
pub fn case_id(mut self, id: impl Into<String>) -> Self {
self.case_id = Some(id.into());
self
}
#[must_use]
pub fn charset(mut self, policy: CharsetPolicy) -> Self {
self.charset = policy;
self
}
#[must_use]
pub fn cancel_whole_message(mut self, reason: CancellationReason) -> Self {
self.whole_message = Some(reason);
self
}
#[must_use]
pub fn additional_info(mut self, text: impl Into<String>) -> Self {
self.whole_message_info = Some(text.into());
self
}
#[must_use]
pub fn add_group(mut self, group: CancellationGroup) -> Self {
self.groups.push(group);
self
}
#[must_use]
pub fn group_count(&self) -> usize {
self.groups.len()
}
#[must_use]
pub fn entry_count(&self) -> usize {
self.groups.iter().map(CancellationGroup::entry_count).sum()
}
#[must_use]
pub fn total_ct(&self) -> Option<i64> {
self.groups
.iter()
.try_fold(0i64, |acc, g| acc.checked_add(g.total_ct()?))
}
pub fn validate(&self) -> Result<(), BuildError> {
let msg = Location::message();
if self.whole_message.is_none() && self.groups.is_empty() {
return Err(BuildError::message(ValidationError::EmptyBatch));
}
if self.whole_message.is_some() && !self.groups.is_empty() {
return Err(BuildError::message(ValidationError::MutuallyExclusive {
field: "Undrlyg",
first: "OrgnlGrpInfAndCxl/GrpCxl",
second: "OrgnlPmtInfAndCxl",
}));
}
check_id("Assgnmt/Id", &self.assignment_id).at(msg)?;
if let Some(id) = &self.case_id {
check_id("Case/Id", id).at(msg)?;
}
let pattern = BicPattern::LettersOnly;
self.assigner
.validate("Assgnmt/Assgnr", self.charset, pattern)
.at(msg)?;
self.assignee
.validate("Assgnmt/Assgne", self.charset, pattern)
.at(msg)?;
self.original.validate().at(msg)?;
match (&self.whole_message, &self.whole_message_info) {
(Some(reason), info) => {
reason.validate("OrgnlGrpInfAndCxl/CxlRsnInf/Rsn").at(msg)?;
self.check_info(info.as_deref()).at(msg)?;
}
(None, Some(_)) => {
return Err(BuildError::message(ValidationError::Requires {
feature: "OrgnlGrpInfAndCxl/CxlRsnInf/AddtlInf",
requires: "cancel_whole_message — the block it belongs to",
}));
}
(None, None) => {}
}
let mut seen = std::collections::BTreeSet::new();
for (i, g) in self.groups.iter().enumerate() {
self.validate_group(i, g, &mut seen)?;
}
Ok(())
}
fn check_info(&self, text: Option<&str>) -> Result<(), ValidationError> {
let Some(text) = text else { return Ok(()) };
check_text(
"CxlRsnInf/AddtlInf",
&self.charset.apply("CxlRsnInf/AddtlInf", text)?,
MAX_ADDITIONAL_INFO_LEN,
)
}
fn validate_group(
&self,
i: usize,
g: &CancellationGroup,
seen: &mut std::collections::BTreeSet<String>,
) -> Result<(), BuildError> {
let at = Location::group(i);
check_id(
"OrgnlPmtInfAndCxl/OrgnlPmtInfId",
&g.original_payment_info_id,
)
.at(at)?;
if !seen.insert(g.original_payment_info_id.clone()) {
return Err(BuildError::group(
i,
ValidationError::Duplicate {
field: "OrgnlPmtInfAndCxl/OrgnlPmtInfId",
value: g.original_payment_info_id.clone(),
},
));
}
if let Some(id) = &g.payment_cancellation_id {
check_id("OrgnlPmtInfAndCxl/PmtCxlId", id).at(at)?;
}
match (&g.whole_group, g.entries.is_empty()) {
(None, true) => return Err(BuildError::group(i, ValidationError::EmptyBatch)),
(Some(_), false) => {
return Err(BuildError::group(
i,
ValidationError::MutuallyExclusive {
field: "OrgnlPmtInfAndCxl",
first: "PmtInfCxl",
second: "TxInf",
},
));
}
(Some(reason), true) => {
reason.validate("CxlRsnInf/Rsn").at(at)?;
self.check_info(g.additional_info.as_deref()).at(at)?;
}
(None, false) => {
if g.additional_info.is_some() {
return Err(BuildError::group(
i,
ValidationError::Requires {
feature: "OrgnlPmtInfAndCxl/CxlRsnInf/AddtlInf",
requires: "cancel_whole_group — the block it belongs to",
},
));
}
for (j, e) in g.entries.iter().enumerate() {
e.validate(self.charset).at(Location::transaction(i, j))?;
}
}
}
Ok(())
}
pub fn build(&self) -> Result<String, BuildError> {
self.validate()?;
let mut buf = String::with_capacity(700 + self.entry_count() * 220);
let _ = self.write_xml_to(&mut buf);
Ok(buf)
}
pub fn write_to<W: std::io::Write>(&self, w: &mut W) -> Result<(), WriteError> {
self.validate()?;
let mut bridge = crate::xml_util::IoWriterBridge {
inner: w,
error: None,
};
if self.write_xml_to(&mut bridge).is_err() {
return Err(WriteError::Io(bridge.error.unwrap_or_else(|| {
std::io::Error::other("XML serialisation failed")
})));
}
Ok(())
}
fn write_xml_to<W: std::fmt::Write>(&self, w: &mut W) -> std::fmt::Result {
let now = self.created_at.unwrap_or_else(IsoDateTime::now);
w.write_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")?;
writeln!(w, "<Document xmlns=\"{NAMESPACE}\">")?;
w.write_str(" <CstmrPmtCxlReq>\n <Assgnmt><Id>")?;
write_escaped(w, &self.assignment_id)?;
w.write_str("</Id>")?;
self.assigner.write_xml(w, "Assgnr", self.charset)?;
self.assignee.write_xml(w, "Assgne", self.charset)?;
writeln!(w, "<CreDtTm>{now}</CreDtTm></Assgnmt>")?;
if let Some(id) = &self.case_id {
w.write_str(" <Case><Id>")?;
write_escaped(w, id)?;
w.write_str("</Id>")?;
self.assigner.write_xml(w, "Cretr", self.charset)?;
w.write_str("</Case>\n")?;
}
if self.entry_count() > 0 {
write!(w, " <CtrlData><NbOfTxs>{}</NbOfTxs>", self.entry_count())?;
if let Some(total) = self.total_ct() {
write!(w, "<CtrlSum>{}</CtrlSum>", ct_to_eur_str(total))?;
}
w.write_str("</CtrlData>\n")?;
}
w.write_str(" <Undrlyg>\n")?;
if let Some(reason) = &self.whole_message {
w.write_str(" <OrgnlGrpInfAndCxl>")?;
self.original.write_reference(w)?;
if let Some(n) = self.original.number_of_transactions {
write!(w, "<NbOfTxs>{n}</NbOfTxs>")?;
}
if let Some(ct) = self.original.control_sum_ct {
write!(w, "<CtrlSum>{}</CtrlSum>", ct_to_eur_str(ct))?;
}
w.write_str("<GrpCxl>true</GrpCxl>")?;
write_reason(w, reason, self.whole_message_info.as_deref(), self.charset)?;
w.write_str("</OrgnlGrpInfAndCxl>\n")?;
}
for g in &self.groups {
self.write_group(w, g)?;
}
w.write_str(" </Undrlyg>\n </CstmrPmtCxlReq>\n</Document>")
}
fn write_group<W: std::fmt::Write>(
&self,
w: &mut W,
g: &CancellationGroup,
) -> std::fmt::Result {
w.write_str(" <OrgnlPmtInfAndCxl>")?;
if let Some(id) = &g.payment_cancellation_id {
w.write_str("<PmtCxlId>")?;
write_escaped(w, id)?;
w.write_str("</PmtCxlId>")?;
}
w.write_str("<OrgnlPmtInfId>")?;
write_escaped(w, &g.original_payment_info_id)?;
w.write_str("</OrgnlPmtInfId>")?;
w.write_str("<OrgnlGrpInf>")?;
self.original.write_reference(w)?;
w.write_str("</OrgnlGrpInf>")?;
if let Some(reason) = &g.whole_group {
w.write_str("<PmtInfCxl>true</PmtInfCxl>")?;
write_reason(w, reason, g.additional_info.as_deref(), self.charset)?;
return w.write_str("</OrgnlPmtInfAndCxl>\n");
}
w.write_str("\n")?;
for entry in &g.entries {
entry.write_xml(w, self.charset)?;
}
w.write_str(" </OrgnlPmtInfAndCxl>\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
DirectDebitEntry, DirectDebitGroup, Pain008Builder, validate_bic, validate_creditor_id,
validate_iban,
};
fn d(s: &str) -> IsoDate {
s.parse().unwrap()
}
fn iban() -> crate::Iban {
validate_iban("DE89370400440532013000").unwrap()
}
fn bank() -> Bic {
validate_bic("COBADEFFXXX").unwrap()
}
fn submitted() -> Pain008Builder {
Pain008Builder::new("Stadtwerke GmbH", "DD-2026-07-001")
.created_at("2026-07-15T09:00:00".parse().unwrap())
.add_group(
DirectDebitGroup::new(
"Stadtwerke GmbH",
&iban(),
&validate_creditor_id("DE98ZZZ09999999999").unwrap(),
d("2026-07-20"),
)
.payment_info_id("PMT-A")
.add_entry(DirectDebitEntry::new(
"MND-1",
d("2024-06-01"),
"Max Mustermann",
iban(),
7_500,
"E2E-1",
)),
)
}
fn recall() -> Camt055Builder {
Camt055Builder::new(
"CXL-1",
"Stadtwerke GmbH",
bank(),
OriginalMessage::from_direct_debit(&submitted()),
)
.created_at("2026-07-15T11:30:00".parse().unwrap())
}
#[test]
fn the_document_has_the_cancellation_shape() {
let xml = recall()
.add_group(
CancellationGroup::new("PMT-A")
.add_entry(CancellationEntry::new("E2E-1", CancellationReason::Dupl)),
)
.build()
.unwrap();
assert!(xml.contains(NAMESPACE));
assert!(xml.contains("<CstmrPmtCxlReq>"));
assert!(xml.contains("<Assgnmt><Id>CXL-1</Id>"));
assert!(xml.contains("<Assgnr><Pty><Nm>Stadtwerke GmbH</Nm></Pty></Assgnr>"));
assert!(xml.contains(
"<Assgne><Agt><FinInstnId><BICFI>COBADEFFXXX</BICFI></FinInstnId></Agt></Assgne>"
));
assert!(xml.contains("<CreDtTm>2026-07-15T11:30:00</CreDtTm>"));
assert!(xml.contains("<OrgnlEndToEndId>E2E-1</OrgnlEndToEndId>"));
assert!(xml.contains("<Cd>DUPL</Cd>"));
}
#[test]
fn the_original_message_is_copied_from_the_builder_that_sent_it() {
let original = OriginalMessage::from_direct_debit(&submitted());
assert_eq!(original.message_id, "DD-2026-07-001");
assert_eq!(original.message_name_id, "pain.008.001.08");
assert_eq!(original.number_of_transactions, Some(1));
assert_eq!(original.control_sum_ct, Some(7_500));
assert_eq!(
original.created_at.map(|t| t.to_string()).as_deref(),
Some("2026-07-15T09:00:00")
);
let xml = Camt055Builder::new("CXL-1", "Stadtwerke GmbH", bank(), original)
.cancel_whole_message(CancellationReason::Tech)
.build()
.unwrap();
assert!(xml.contains("<OrgnlMsgId>DD-2026-07-001</OrgnlMsgId>"));
assert!(xml.contains("<OrgnlMsgNmId>pain.008.001.08</OrgnlMsgNmId>"));
assert!(xml.contains("<OrgnlCreDtTm>2026-07-15T09:00:00</OrgnlCreDtTm>"));
assert!(xml.contains("<GrpCxl>true</GrpCxl>"));
}
#[test]
fn an_unpinned_creation_timestamp_is_not_invented() {
let unpinned = Pain008Builder::new("Acme", "DD-9");
assert_eq!(
OriginalMessage::from_direct_debit(&unpinned).created_at,
None
);
let xml = Camt055Builder::new(
"CXL-9",
"Acme",
bank(),
OriginalMessage::from_direct_debit(&unpinned),
)
.cancel_whole_message(CancellationReason::Cust)
.build()
.unwrap();
assert!(!xml.contains("OrgnlCreDtTm"));
}
#[test]
fn a_transaction_recall_copies_the_amount_and_date_it_is_matched_by() {
let group = DirectDebitGroup::new(
"Stadtwerke GmbH",
&iban(),
&validate_creditor_id("DE98ZZZ09999999999").unwrap(),
d("2026-07-20"),
);
let entry = DirectDebitEntry::new("MND-1", d("2024-06-01"), "Max", iban(), 7_500, "E2E-1");
let xml = recall()
.add_group(CancellationGroup::new("PMT-A").add_entry(
CancellationEntry::from_direct_debit(&group, &entry, CancellationReason::Frad),
))
.build()
.unwrap();
assert!(xml.contains("<OrgnlInstdAmt Ccy=\"EUR\">75.00</OrgnlInstdAmt>"));
assert!(xml.contains("<OrgnlReqdColltnDt>2026-07-20</OrgnlReqdColltnDt>"));
assert!(
!xml.contains("OrgnlReqdExctnDt"),
"a collection has no execution date"
);
assert!(xml.contains("<CtrlData><NbOfTxs>1</NbOfTxs><CtrlSum>75.00</CtrlSum></CtrlData>"));
}
#[test]
fn a_whole_group_recall_lists_nothing_and_writes_no_control_data() {
let xml = recall()
.add_group(CancellationGroup::new("PMT-A").cancel_whole_group(CancellationReason::Upay))
.build()
.unwrap();
assert!(xml.contains("<PmtInfCxl>true</PmtInfCxl>"));
assert!(!xml.contains("CtrlData"));
assert!(!xml.contains("TxInf"));
assert!(xml.contains("<OrgnlGrpInf><OrgnlMsgId>DD-2026-07-001</OrgnlMsgId>"));
}
#[test]
fn the_three_scopes_are_alternatives() {
let both = recall()
.cancel_whole_message(CancellationReason::Tech)
.add_group(
CancellationGroup::new("PMT-A")
.add_entry(CancellationEntry::new("E2E-1", CancellationReason::Tech)),
)
.build()
.unwrap_err();
assert!(matches!(
both.kind,
ValidationError::MutuallyExclusive {
field: "Undrlyg",
..
}
));
let group_both = recall()
.add_group(
CancellationGroup::new("PMT-A")
.cancel_whole_group(CancellationReason::Tech)
.add_entry(CancellationEntry::new("E2E-1", CancellationReason::Tech)),
)
.build()
.unwrap_err();
assert_eq!(group_both.location, Location::group(0));
assert!(matches!(
group_both.kind,
ValidationError::MutuallyExclusive {
field: "OrgnlPmtInfAndCxl",
..
}
));
}
#[test]
fn free_text_with_no_block_to_live_in_is_rejected_not_dropped() {
let err = recall()
.additional_info("Fehlerhafter Lauf")
.add_group(
CancellationGroup::new("PMT-A")
.add_entry(CancellationEntry::new("E2E-1", CancellationReason::Dupl)),
)
.build()
.unwrap_err();
assert!(matches!(
err.kind,
ValidationError::Requires {
feature: "OrgnlGrpInfAndCxl/CxlRsnInf/AddtlInf",
..
}
));
let err = recall()
.add_group(
CancellationGroup::new("PMT-A")
.additional_info("Lauf zurueckgezogen")
.add_entry(CancellationEntry::new("E2E-1", CancellationReason::Dupl)),
)
.build()
.unwrap_err();
assert_eq!(err.location, Location::group(0));
assert!(matches!(
err.kind,
ValidationError::Requires {
feature: "OrgnlPmtInfAndCxl/CxlRsnInf/AddtlInf",
..
}
));
let xml = recall()
.add_group(
CancellationGroup::new("PMT-A").add_entry(
CancellationEntry::new("E2E-1", CancellationReason::Dupl)
.additional_info("Doppelte Einreichung"),
),
)
.build()
.unwrap();
assert!(xml.contains("<AddtlInf>Doppelte Einreichung</AddtlInf>"));
}
#[test]
fn a_request_that_cancels_nothing_is_rejected() {
assert!(matches!(
recall().build().unwrap_err().kind,
ValidationError::EmptyBatch
));
let err = recall()
.add_group(CancellationGroup::new("PMT-A"))
.build()
.unwrap_err();
assert_eq!(err.location, Location::group(0));
assert!(matches!(err.kind, ValidationError::EmptyBatch));
}
#[test]
fn two_groups_may_not_name_one_submitted_group() {
let err = recall()
.add_group(
CancellationGroup::new("PMT-A")
.add_entry(CancellationEntry::new("E2E-1", CancellationReason::Dupl)),
)
.add_group(
CancellationGroup::new("PMT-A")
.add_entry(CancellationEntry::new("E2E-2", CancellationReason::Dupl)),
)
.build()
.unwrap_err();
assert!(matches!(
err.kind,
ValidationError::Duplicate {
field: "OrgnlPmtInfAndCxl/OrgnlPmtInfId",
..
}
));
}
#[test]
fn a_proprietary_reason_goes_to_prtry_not_to_cd() {
let reason: CancellationReason = "XY99".parse().unwrap();
assert!(!reason.is_iso_code());
let xml = recall()
.add_group(
CancellationGroup::new("PMT-A").add_entry(CancellationEntry::new("E2E-1", reason)),
)
.build()
.unwrap();
assert!(xml.contains("<Rsn><Prtry>XY99</Prtry></Rsn>"));
assert!(!xml.contains("<Cd>XY99</Cd>"));
}
#[test]
fn additional_info_is_capped_at_max105text_and_transliterated() {
let ok = recall()
.add_group(
CancellationGroup::new("PMT-A").add_entry(
CancellationEntry::new("E2E-1", CancellationReason::Cust)
.additional_info("Rückruf für Müller"),
),
)
.build()
.unwrap();
assert!(ok.contains("<AddtlInf>Rueckruf fuer Mueller</AddtlInf>"));
let err = recall()
.add_group(
CancellationGroup::new("PMT-A").add_entry(
CancellationEntry::new("E2E-1", CancellationReason::Cust)
.additional_info("A".repeat(106)),
),
)
.build()
.unwrap_err();
assert!(matches!(
err.kind,
ValidationError::TooLong {
field: "CxlRsnInf/AddtlInf",
max: 105,
actual: 106
}
));
}
#[test]
fn the_assignee_bic_is_held_to_the_pattern_this_schema_uses() {
let modern = validate_bic("E097DEFF").unwrap();
let err = Camt055Builder::new(
"CXL-1",
"Acme",
modern,
OriginalMessage::new("DD-1", "pain.008.001.08"),
)
.cancel_whole_message(CancellationReason::Tech)
.build()
.unwrap_err();
assert!(matches!(
err.kind,
ValidationError::SchemaPattern {
field: "Assgnmt/Assgne",
schema: "camt.055.001.05",
..
}
));
}
#[test]
fn streaming_matches_the_in_memory_build() {
let builder = recall().add_group(
CancellationGroup::new("PMT-A")
.add_entry(CancellationEntry::new("E2E-1", CancellationReason::Dupl)),
);
let mut streamed = Vec::new();
builder.write_to(&mut streamed).unwrap();
assert_eq!(
String::from_utf8(streamed).unwrap(),
builder.build().unwrap()
);
}
#[test]
fn reason_codes_round_trip_and_reject_malformed_custom_ones() {
for code in [
"DUPL", "AGNT", "CURR", "CUST", "UPAY", "CUTA", "TECH", "FRAD",
] {
let parsed: CancellationReason = code.parse().unwrap();
assert_eq!(parsed.as_code(), code);
assert!(parsed.is_iso_code());
assert!(parsed.validate("Rsn").is_ok());
}
assert_eq!(
"fraud".parse::<CancellationReason>().unwrap(),
CancellationReason::Frad
);
assert!(
CancellationReason::Other("BAD CODE".to_owned())
.validate("Rsn")
.is_err()
);
assert!(
CancellationReason::Other(String::new())
.validate("Rsn")
.is_err()
);
}
}