use std::str::FromStr;
use crate::creditor_id::CreditorId;
use crate::date::IsoDate;
use crate::pain008::{DirectDebitEntry, DirectDebitGroup, DirectDebitScheme, SequenceType};
use crate::validate::{
BuildError, CharsetPolicy, Locate, Location, MAX_ID_LEN, ValidationError, WriteError,
check_amount, check_id, check_name, truncate_chars,
};
use crate::xml_util::{write_escaped, write_eur};
use crate::{Bic, Iban, IsoDateTime, ct_to_eur_str};
pub const NAMESPACE: &str = "urn:iso:std:iso:20022:tech:xsd:pain.007.001.09";
pub const MESSAGE_ID: &str = "pain.007.001.09";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ReversalReason {
Ms02,
Ms03,
Am05,
Dupl,
Cust,
Frad,
Tech,
Ac04,
Other(String),
}
impl ReversalReason {
#[must_use]
pub fn as_code(&self) -> &str {
match self {
Self::Ms02 => "MS02",
Self::Ms03 => "MS03",
Self::Am05 => "AM05",
Self::Dupl => "DUPL",
Self::Cust => "CUST",
Self::Frad => "FRAD",
Self::Tech => "TECH",
Self::Ac04 => "AC04",
Self::Other(code) => code,
}
}
pub fn validate(&self, field: &'static str) -> Result<(), ValidationError> {
let code = self.as_code();
let len = code.chars().count();
if code.trim().is_empty() {
return Err(ValidationError::Empty { field });
}
if len > 4 {
return Err(ValidationError::TooLong {
field,
max: 4,
actual: len,
});
}
match code.chars().find(|c| !c.is_ascii_alphanumeric()) {
Some(ch) => Err(ValidationError::InvalidCharacter { field, ch }),
None => Ok(()),
}
}
}
impl std::fmt::Display for ReversalReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
impl FromStr for ReversalReason {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.trim().to_ascii_uppercase().as_str() {
"MS02" => Self::Ms02,
"MS03" => Self::Ms03,
"AM05" => Self::Am05,
"DUPL" => Self::Dupl,
"CUST" => Self::Cust,
"FRAD" => Self::Frad,
"TECH" => Self::Tech,
"AC04" => Self::Ac04,
other => Self::Other(other.to_owned()),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OriginalCollection {
mandate_ref: String,
mandate_signed_at: IsoDate,
collection_date: Option<IsoDate>,
creditor_id: Option<CreditorId>,
payment_type: Option<(DirectDebitScheme, SequenceType)>,
debtor: Option<(String, Iban)>,
debtor_bic: Option<Bic>,
creditor: Option<(String, Iban)>,
creditor_bic: Option<Bic>,
}
impl OriginalCollection {
pub fn new(mandate_ref: impl Into<String>, mandate_signed_at: IsoDate) -> Self {
Self {
mandate_ref: mandate_ref.into(),
mandate_signed_at,
collection_date: None,
creditor_id: None,
payment_type: None,
debtor: None,
debtor_bic: None,
creditor: None,
creditor_bic: None,
}
}
#[must_use]
pub fn collection_date(mut self, date: IsoDate) -> Self {
self.collection_date = Some(date);
self
}
#[must_use]
pub fn creditor_id(mut self, creditor_id: CreditorId) -> Self {
self.creditor_id = Some(creditor_id);
self
}
#[must_use]
pub fn payment_type(mut self, scheme: DirectDebitScheme, sequence: SequenceType) -> Self {
self.payment_type = Some((scheme, sequence));
self
}
#[must_use]
pub fn debtor(mut self, name: impl Into<String>, iban: Iban) -> Self {
self.debtor = Some((name.into(), iban));
self
}
#[must_use]
pub fn debtor_agent(mut self, bic: Bic) -> Self {
self.debtor_bic = Some(bic);
self
}
#[must_use]
pub fn creditor(mut self, name: impl Into<String>, iban: Iban) -> Self {
self.creditor = Some((name.into(), iban));
self
}
#[must_use]
pub fn creditor_agent(mut self, bic: Bic) -> Self {
self.creditor_bic = Some(bic);
self
}
#[must_use]
pub fn mandate_ref(&self) -> &str {
&self.mandate_ref
}
#[must_use]
pub const fn mandate_signed_at(&self) -> IsoDate {
self.mandate_signed_at
}
fn validate(&self, charset: CharsetPolicy) -> Result<(), ValidationError> {
check_id("MndtRltdInf/MndtId", &self.mandate_ref)?;
if let Some((name, _)) = &self.debtor {
check_name("Dbtr/Nm", &charset.apply("Dbtr/Nm", name)?)?;
}
if let Some((name, _)) = &self.creditor {
check_name("Cdtr/Nm", &charset.apply("Cdtr/Nm", name)?)?;
}
Ok(())
}
fn write_xml<W: std::fmt::Write>(&self, w: &mut W, charset: CharsetPolicy) -> std::fmt::Result {
fn party<W: std::fmt::Write>(
w: &mut W,
tag: &'static str,
name: &str,
iban: &Iban,
charset: CharsetPolicy,
) -> std::fmt::Result {
write!(w, " <{tag}><Pty><Nm>")?;
write_escaped(w, &charset.render(name))?;
writeln!(w, "</Nm></Pty></{tag}>")?;
writeln!(
w,
" <{tag}Acct><Id><IBAN>{}</IBAN></Id></{tag}Acct>",
iban.as_str()
)
}
w.write_str(" <OrgnlTxRef>\n")?;
if let Some(date) = self.collection_date {
writeln!(w, " <ReqdColltnDt>{date}</ReqdColltnDt>")?;
}
if let Some(ci) = &self.creditor_id {
w.write_str(" <CdtrSchmeId><Id><PrvtId><Othr><Id>")?;
w.write_str(ci.as_str())?;
w.write_str(
"</Id><SchmeNm><Prtry>SEPA</Prtry></SchmeNm></Othr></PrvtId></Id></CdtrSchmeId>\n",
)?;
}
if let Some((scheme, sequence)) = self.payment_type {
writeln!(
w,
" <PmtTpInf><SvcLvl><Cd>SEPA</Cd></SvcLvl>\
<LclInstrm><Cd>{}</Cd></LclInstrm><SeqTp>{}</SeqTp></PmtTpInf>",
scheme.as_code(),
sequence.as_code()
)?;
}
w.write_str(" <MndtRltdInf><MndtId>")?;
write_escaped(w, &self.mandate_ref)?;
writeln!(
w,
"</MndtId><DtOfSgntr>{}</DtOfSgntr><AmdmntInd>false</AmdmntInd></MndtRltdInf>",
self.mandate_signed_at
)?;
if let Some((name, iban)) = &self.debtor {
party(w, "Dbtr", name, iban, charset)?;
}
if let Some(bic) = &self.debtor_bic {
writeln!(
w,
" <DbtrAgt><FinInstnId><BICFI>{}</BICFI></FinInstnId></DbtrAgt>",
bic.as_str()
)?;
}
if let Some(bic) = &self.creditor_bic {
writeln!(
w,
" <CdtrAgt><FinInstnId><BICFI>{}</BICFI></FinInstnId></CdtrAgt>",
bic.as_str()
)?;
}
if let Some((name, iban)) = &self.creditor {
party(w, "Cdtr", name, iban, charset)?;
}
w.write_str(" </OrgnlTxRef>\n")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ReversalEntry {
pub original_end_to_end_id: String,
pub original_amount_ct: i64,
pub reversed_amount_ct: Option<i64>,
pub reason: ReversalReason,
pub original: OriginalCollection,
}
impl ReversalEntry {
pub fn new(
original_end_to_end_id: impl Into<String>,
original_amount_ct: i64,
reason: ReversalReason,
original: OriginalCollection,
) -> Self {
Self {
original_end_to_end_id: original_end_to_end_id.into(),
original_amount_ct,
reversed_amount_ct: None,
reason,
original,
}
}
#[must_use]
pub fn reverse(
group: &DirectDebitGroup,
entry: &DirectDebitEntry,
reason: ReversalReason,
) -> Self {
Self {
original_end_to_end_id: entry.end_to_end_id.clone(),
original_amount_ct: entry.amount_ct,
reversed_amount_ct: None,
reason,
original: {
let mut original =
OriginalCollection::new(&entry.mandate_ref, entry.mandate_signed_at)
.collection_date(group.collection_date)
.creditor_id(group.creditor_id.clone())
.payment_type(group.scheme, group.sequence_type)
.debtor(&entry.debtor_name, entry.debtor_iban.clone())
.creditor(&group.creditor_name, group.creditor_iban.clone());
if let Some(bic) = entry.debtor_bic.clone() {
original = original.debtor_agent(bic);
}
if let Some(bic) = group.creditor_bic.clone() {
original = original.creditor_agent(bic);
}
original
},
}
}
#[must_use]
pub fn reversed_amount(mut self, amount_ct: i64) -> Self {
self.reversed_amount_ct = Some(amount_ct);
self
}
#[must_use]
pub const fn effective_amount_ct(&self) -> i64 {
match self.reversed_amount_ct {
Some(amount) => amount,
None => self.original_amount_ct,
}
}
fn validate(&self, charset: CharsetPolicy) -> Result<(), ValidationError> {
check_id("TxInf/OrgnlEndToEndId", &self.original_end_to_end_id)?;
check_amount("TxInf/OrgnlInstdAmt", self.original_amount_ct)?;
check_amount("TxInf/RvsdInstdAmt", self.effective_amount_ct())?;
if self.effective_amount_ct() > self.original_amount_ct {
return Err(ValidationError::AmountOutOfRange {
field: "TxInf/RvsdInstdAmt",
amount_ct: self.effective_amount_ct(),
});
}
self.reason.validate("RvslRsnInf/Rsn/Cd")?;
self.original.validate(charset)?;
Ok(())
}
fn write_xml<W: std::fmt::Write>(&self, w: &mut W, charset: CharsetPolicy) -> std::fmt::Result {
w.write_str(" <TxInf>\n <OrgnlEndToEndId>")?;
write_escaped(w, &self.original_end_to_end_id)?;
w.write_str("</OrgnlEndToEndId>\n <OrgnlInstdAmt Ccy=\"EUR\">")?;
write_eur(w, self.original_amount_ct)?;
w.write_str("</OrgnlInstdAmt>\n <RvsdInstdAmt Ccy=\"EUR\">")?;
write_eur(w, self.effective_amount_ct())?;
w.write_str("</RvsdInstdAmt>\n <ChrgBr>SLEV</ChrgBr>\n")?;
writeln!(
w,
" <RvslRsnInf><Rsn><Cd>{}</Cd></Rsn></RvslRsnInf>",
self.reason.as_code()
)?;
self.original.write_xml(w, charset)?;
w.write_str(" </TxInf>\n")
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ReversalGroup {
original_payment_info_id: String,
reversal_payment_info_id: Option<String>,
batch_booking: Option<bool>,
entries: Vec<ReversalEntry>,
}
impl ReversalGroup {
pub fn new(original_payment_info_id: impl Into<String>) -> Self {
Self {
original_payment_info_id: original_payment_info_id.into(),
reversal_payment_info_id: None,
batch_booking: None,
entries: Vec::new(),
}
}
#[must_use]
pub fn reversal_payment_info_id(mut self, id: impl Into<String>) -> Self {
self.reversal_payment_info_id = Some(id.into());
self
}
#[must_use]
pub fn batch_booking(mut self, batch: bool) -> Self {
self.batch_booking = Some(batch);
self
}
#[must_use]
pub fn add_entry(mut self, entry: ReversalEntry) -> Self {
self.entries.push(entry);
self
}
#[must_use]
pub fn add_entries(mut self, entries: impl IntoIterator<Item = ReversalEntry>) -> Self {
self.entries.extend(entries);
self
}
#[must_use]
pub fn entry_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn total_ct(&self) -> i64 {
self.entries
.iter()
.fold(0i64, |acc, e| acc.saturating_add(e.effective_amount_ct()))
}
}
#[derive(Debug, Clone)]
pub struct Pain007Builder {
initiating_party: String,
msg_id: String,
created_at: Option<IsoDateTime>,
creditor_agent: Option<Bic>,
original_msg_id: String,
original_msg_name_id: String,
charset: CharsetPolicy,
groups: Vec<ReversalGroup>,
}
impl Pain007Builder {
pub fn new(initiating_party: impl Into<String>, original_msg_id: impl Into<String>) -> Self {
Self {
initiating_party: initiating_party.into(),
msg_id: crate::pain008::default_msg_id("rvsl"),
created_at: None,
creditor_agent: None,
original_msg_id: original_msg_id.into(),
original_msg_name_id: crate::pain008::DirectDebitSchema::default()
.message_id()
.to_owned(),
charset: CharsetPolicy::default(),
groups: Vec::new(),
}
}
#[must_use]
pub fn msg_id(mut self, id: impl Into<String>) -> Self {
self.msg_id = id.into();
self
}
#[must_use]
pub fn created_at(mut self, timestamp: IsoDateTime) -> Self {
self.created_at = Some(timestamp);
self
}
#[must_use]
pub fn original_schema(mut self, schema: crate::pain008::DirectDebitSchema) -> Self {
self.original_msg_name_id.clear();
self.original_msg_name_id.push_str(schema.message_id());
self
}
#[must_use]
pub fn creditor_agent(mut self, bic: Bic) -> Self {
self.creditor_agent = Some(bic);
self
}
#[must_use]
pub fn charset(mut self, policy: CharsetPolicy) -> Self {
self.charset = policy;
self
}
#[must_use]
pub fn add_group(mut self, group: ReversalGroup) -> 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(ReversalGroup::entry_count).sum()
}
#[must_use]
pub fn total_ct(&self) -> i64 {
self.groups
.iter()
.fold(0i64, |acc, g| acc.saturating_add(g.total_ct()))
}
fn reversal_payment_info_id(&self, index: usize) -> Option<String> {
let explicit = self
.groups
.get(index)
.and_then(|g| g.reversal_payment_info_id.clone());
if explicit.is_some() {
return explicit;
}
if self.groups.len() <= 1 {
return Some(self.msg_id.clone());
}
let suffix = format!("-{}", index + 1);
let keep = MAX_ID_LEN.saturating_sub(suffix.chars().count());
Some(format!("{}{suffix}", truncate_chars(&self.msg_id, keep)))
}
pub fn validate(&self) -> Result<(), BuildError> {
if self.groups.is_empty() {
return Err(BuildError::message(ValidationError::EmptyBatch));
}
let msg = Location::message();
check_id("GrpHdr/MsgId", &self.msg_id).at(msg)?;
check_id("OrgnlGrpInf/OrgnlMsgId", &self.original_msg_id).at(msg)?;
check_name(
"InitgPty/Nm",
&self
.charset
.apply("InitgPty/Nm", &self.initiating_party)
.at(msg)?,
)
.at(msg)?;
let mut total: i64 = 0;
for (i, g) in self.groups.iter().enumerate() {
let at = Location::group(i);
if g.entries.is_empty() {
return Err(BuildError::group(i, ValidationError::EmptyBatch));
}
check_id(
"OrgnlPmtInfAndRvsl/OrgnlPmtInfId",
&g.original_payment_info_id,
)
.at(at)?;
if let Some(id) = self.reversal_payment_info_id(i) {
check_id("OrgnlPmtInfAndRvsl/RvslPmtInfId", &id).at(at)?;
}
for (j, e) in g.entries.iter().enumerate() {
let at = Location::transaction(i, j);
e.validate(self.charset).at(at)?;
total = total
.checked_add(e.effective_amount_ct())
.ok_or(BuildError {
location: at,
kind: ValidationError::ControlSumOverflow,
})?;
}
}
Ok(())
}
pub fn build(&self) -> Result<String, BuildError> {
self.validate()?;
let mut buf = String::with_capacity(700 + self.entry_count() * 900);
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);
let initiating = self.charset.render(&self.initiating_party);
w.write_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")?;
writeln!(w, "<Document xmlns=\"{NAMESPACE}\">")?;
w.write_str(" <CstmrPmtRvsl>\n <GrpHdr>\n <MsgId>")?;
write_escaped(w, &self.msg_id)?;
write!(w, "</MsgId>\n <CreDtTm>{now}</CreDtTm>\n")?;
write!(
w,
" <NbOfTxs>{}</NbOfTxs>\n <CtrlSum>{}</CtrlSum>\n",
self.entry_count(),
ct_to_eur_str(self.total_ct())
)?;
w.write_str(" <InitgPty><Nm>")?;
write_escaped(w, &initiating)?;
w.write_str("</Nm></InitgPty>\n")?;
if let Some(bic) = &self.creditor_agent {
writeln!(
w,
" <CdtrAgt><FinInstnId><BICFI>{}</BICFI></FinInstnId></CdtrAgt>",
bic.as_str()
)?;
}
w.write_str(" </GrpHdr>\n <OrgnlGrpInf>\n <OrgnlMsgId>")?;
write_escaped(w, &self.original_msg_id)?;
w.write_str("</OrgnlMsgId>\n <OrgnlMsgNmId>")?;
w.write_str(&self.original_msg_name_id)?;
w.write_str("</OrgnlMsgNmId>\n </OrgnlGrpInf>\n")?;
for (i, g) in self.groups.iter().enumerate() {
w.write_str(" <OrgnlPmtInfAndRvsl>\n")?;
if let Some(id) = self.reversal_payment_info_id(i) {
w.write_str(" <RvslPmtInfId>")?;
write_escaped(w, &id)?;
w.write_str("</RvslPmtInfId>\n")?;
}
w.write_str(" <OrgnlPmtInfId>")?;
write_escaped(w, &g.original_payment_info_id)?;
w.write_str("</OrgnlPmtInfId>\n")?;
if let Some(batch) = g.batch_booking {
writeln!(w, " <BtchBookg>{batch}</BtchBookg>")?;
}
for entry in &g.entries {
entry.write_xml(w, self.charset)?;
}
w.write_str(" </OrgnlPmtInfAndRvsl>\n")?;
}
w.write_str(" </CstmrPmtRvsl>\n</Document>")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Pain008Builder, validate_bic, validate_creditor_id, validate_iban};
fn creditor_iban() -> Iban {
validate_iban("DE89370400440532013000").unwrap()
}
fn debtor_iban() -> Iban {
validate_iban("NL91ABNA0417164300").unwrap()
}
fn ci() -> CreditorId {
validate_creditor_id("DE98ZZZ09999999999").unwrap()
}
fn d(s: &str) -> IsoDate {
s.parse().unwrap()
}
fn original_group() -> DirectDebitGroup {
DirectDebitGroup::new("Stadtwerke GmbH", &creditor_iban(), &ci())
.sequence_type(SequenceType::Frst)
.collection_date(d("2026-07-20"))
.creditor_bic(validate_bic("COBADEFFXXX").unwrap())
}
fn original_entry() -> DirectDebitEntry {
DirectDebitEntry::new(
"MND-42",
d("2024-06-01"),
"Max Mustermann",
debtor_iban(),
7_500,
"E2E-1",
)
}
fn reversal() -> Pain007Builder {
Pain007Builder::new("Stadtwerke GmbH", "DD-2026-07-001")
.msg_id("RVSL-001")
.created_at("2026-07-25T10:00:00".parse().unwrap())
.add_group(
ReversalGroup::new("DD-2026-07-001").add_entry(ReversalEntry::reverse(
&original_group(),
&original_entry(),
ReversalReason::Ms02,
)),
)
}
#[test]
fn the_document_has_the_reversal_shape() {
let xml = reversal().build().unwrap();
assert!(xml.contains("urn:iso:std:iso:20022:tech:xsd:pain.007.001.09"));
assert!(xml.contains("<CstmrPmtRvsl>"));
assert!(xml.contains("<MsgId>RVSL-001</MsgId>"));
assert!(xml.contains("<OrgnlMsgId>DD-2026-07-001</OrgnlMsgId>"));
assert!(xml.contains("<OrgnlMsgNmId>pain.008.001.08</OrgnlMsgNmId>"));
assert!(xml.contains("<NbOfTxs>1</NbOfTxs>"));
assert!(xml.contains("<CtrlSum>75.00</CtrlSum>"));
assert!(xml.contains("<ChrgBr>SLEV</ChrgBr>"));
}
#[test]
fn reversing_from_the_original_copies_every_identifying_field() {
let xml = reversal().build().unwrap();
assert!(xml.contains("<OrgnlEndToEndId>E2E-1</OrgnlEndToEndId>"));
assert!(xml.contains("<OrgnlInstdAmt Ccy=\"EUR\">75.00</OrgnlInstdAmt>"));
assert!(xml.contains("<RvsdInstdAmt Ccy=\"EUR\">75.00</RvsdInstdAmt>"));
assert!(xml.contains("<ReqdColltnDt>2026-07-20</ReqdColltnDt>"));
assert!(xml.contains("DE98ZZZ09999999999"));
assert!(xml.contains("<LclInstrm><Cd>CORE</Cd></LclInstrm><SeqTp>FRST</SeqTp>"));
assert!(xml.contains("<MndtId>MND-42</MndtId><DtOfSgntr>2024-06-01</DtOfSgntr>"));
assert!(xml.contains("<Dbtr><Pty><Nm>Max Mustermann</Nm></Pty></Dbtr>"));
assert!(xml.contains("<Cdtr><Pty><Nm>Stadtwerke GmbH</Nm></Pty></Cdtr>"));
assert!(xml.contains("<IBAN>NL91ABNA0417164300</IBAN>"));
assert!(xml.contains("<BICFI>COBADEFFXXX</BICFI>"));
}
#[test]
fn a_hand_built_reference_carries_the_mandate_the_dk_requires() {
let xml = Pain007Builder::new("Stadtwerke GmbH", "DD-1")
.msg_id("RVSL-REF")
.add_group(ReversalGroup::new("DD-1").add_entry(ReversalEntry::new(
"E2E-9",
1_000,
ReversalReason::Am05,
OriginalCollection::new("MND-9", d("2024-01-01")),
)))
.build()
.unwrap();
assert!(xml.contains("<OrgnlEndToEndId>E2E-9</OrgnlEndToEndId>"));
assert!(xml.contains("<Cd>AM05</Cd>"));
assert!(
xml.contains("<MndtRltdInf><MndtId>MND-9</MndtId><DtOfSgntr>2024-01-01</DtOfSgntr>")
);
assert!(!xml.contains("PmtTpInf"));
assert!(!xml.contains("<Dbtr>"));
}
#[test]
fn a_partial_reversal_may_not_exceed_what_was_collected() {
let build = |reversed| {
Pain007Builder::new("Stadtwerke GmbH", "DD-1")
.msg_id("RVSL-PART")
.add_group(
ReversalGroup::new("DD-1").add_entry(
ReversalEntry::new(
"E2E-1",
7_500,
ReversalReason::Ms02,
OriginalCollection::new("MND-1", d("2024-01-01")),
)
.reversed_amount(reversed),
),
)
.build()
};
let xml = build(2_500).unwrap();
assert!(xml.contains("<OrgnlInstdAmt Ccy=\"EUR\">75.00</OrgnlInstdAmt>"));
assert!(xml.contains("<RvsdInstdAmt Ccy=\"EUR\">25.00</RvsdInstdAmt>"));
assert_eq!(
build(9_000).unwrap_err().kind,
ValidationError::AmountOutOfRange {
field: "TxInf/RvsdInstdAmt",
amount_ct: 9_000,
}
);
assert!(build(0).is_err());
}
#[test]
fn several_groups_get_distinct_reversal_ids_within_max35text() {
let msg_id = "R".repeat(35);
let b = (0..3).fold(
Pain007Builder::new("Stadtwerke GmbH", "DD-1").msg_id(&msg_id),
|b, i| {
b.add_group(
ReversalGroup::new(format!("DD-1-{i}")).add_entry(ReversalEntry::new(
"E2E",
100,
ReversalReason::Ms02,
OriginalCollection::new("MND-1", d("2024-01-01")),
)),
)
},
);
let xml = b.build().unwrap();
let ids: Vec<&str> = xml
.split("<RvslPmtInfId>")
.skip(1)
.map(|c| c.split('<').next().unwrap())
.collect();
assert_eq!(ids.len(), 3);
for id in &ids {
assert!(id.chars().count() <= 35, "{id} exceeds Max35Text");
}
assert_eq!(
ids.iter().collect::<std::collections::BTreeSet<_>>().len(),
3
);
}
#[test]
fn an_empty_reversal_is_rejected() {
assert_eq!(
Pain007Builder::new("Stadtwerke GmbH", "DD-1")
.msg_id("R")
.build(),
Err(BuildError::message(ValidationError::EmptyBatch))
);
assert_eq!(
Pain007Builder::new("Stadtwerke GmbH", "DD-1")
.msg_id("R")
.add_group(ReversalGroup::new("DD-1"))
.build(),
Err(BuildError::group(0, ValidationError::EmptyBatch))
);
}
#[test]
fn errors_name_the_group_and_transaction() {
let err = Pain007Builder::new("Stadtwerke GmbH", "DD-1")
.msg_id("RVSL-LOC")
.add_group(
ReversalGroup::new("DD-1")
.add_entry(ReversalEntry::new(
"E2E-1",
100,
ReversalReason::Ms02,
OriginalCollection::new("MND-1", d("2024-01-01")),
))
.add_entry(ReversalEntry::new(
"E2E-2",
0,
ReversalReason::Ms02,
OriginalCollection::new("MND-2", d("2024-01-01")),
)),
)
.build()
.unwrap_err();
assert_eq!(err.location, Location::transaction(0, 1));
assert!(err.to_string().starts_with("PmtInf[0]/Tx[1]: "));
}
#[test]
fn reason_codes_round_trip_and_reject_malformed_custom_ones() {
for code in ["MS02", "AM05", "DUPL", "FRAD"] {
assert_eq!(code.parse::<ReversalReason>().unwrap().as_code(), code);
}
assert_eq!(
"zzzz".parse::<ReversalReason>().unwrap(),
ReversalReason::Other("ZZZZ".to_owned())
);
assert!(matches!(
ReversalReason::Other("TOOLONG".to_owned()).validate("Rsn/Cd"),
Err(ValidationError::TooLong { .. })
));
assert!(matches!(
ReversalReason::Other("A-B".to_owned()).validate("Rsn/Cd"),
Err(ValidationError::InvalidCharacter { .. })
));
assert_eq!(ReversalReason::Ms02.to_string(), "MS02");
}
#[test]
fn text_is_transliterated_like_every_other_message() {
let group = DirectDebitGroup::new("Müller & Söhne", &creditor_iban(), &ci())
.collection_date(d("2026-07-20"));
let entry = DirectDebitEntry::new(
"MND-1",
d("2024-06-01"),
"Jörg Groß",
debtor_iban(),
100,
"E2E-1",
);
let xml = Pain007Builder::new("Müller & Söhne", "DD-1")
.msg_id("RVSL-UML")
.add_group(ReversalGroup::new("DD-1").add_entry(ReversalEntry::reverse(
&group,
&entry,
ReversalReason::Ms02,
)))
.build()
.unwrap();
assert!(xml.contains("Mueller + Soehne"));
assert!(xml.contains("Joerg Gross"));
}
#[test]
fn streaming_matches_the_in_memory_build() {
let direct = reversal().build().unwrap();
let mut buf: Vec<u8> = Vec::new();
reversal().write_to(&mut buf).unwrap();
assert_eq!(direct, String::from_utf8(buf).unwrap());
let mut empty: Vec<u8> = Vec::new();
assert!(
Pain007Builder::new("X", "DD-1")
.write_to(&mut empty)
.is_err()
);
assert!(empty.is_empty());
}
#[test]
fn the_original_schema_is_named_so_the_bank_can_match_it() {
let xml = reversal()
.original_schema(crate::pain008::DirectDebitSchema::IsoV2)
.build()
.unwrap();
assert!(xml.contains("<OrgnlMsgNmId>pain.008.001.02</OrgnlMsgNmId>"));
}
#[test]
fn a_reversal_round_trips_the_payment_info_id_a_collection_emitted() {
let collection = Pain008Builder::new("Stadtwerke GmbH")
.msg_id("DD-2026-07-001")
.add_group(original_group().add_entry(original_entry()))
.build()
.unwrap();
let pmt_inf_id = collection
.split("<PmtInfId>")
.nth(1)
.unwrap()
.split('<')
.next()
.unwrap();
assert_eq!(pmt_inf_id, "DD-2026-07-001");
let xml = Pain007Builder::new("Stadtwerke GmbH", "DD-2026-07-001")
.msg_id("RVSL-1")
.add_group(
ReversalGroup::new(pmt_inf_id).add_entry(ReversalEntry::reverse(
&original_group(),
&original_entry(),
ReversalReason::Ms02,
)),
)
.build()
.unwrap();
assert!(xml.contains("<OrgnlPmtInfId>DD-2026-07-001</OrgnlPmtInfId>"));
}
}