use std::str::FromStr;
use crate::address::PostalAddress;
use crate::bic::BicPattern;
use crate::creditor_id::CreditorId;
use crate::date::IsoDate;
use crate::party::Party;
use crate::purpose::{CategoryPurpose, Purpose};
use crate::reference::RemittanceInfo;
use crate::validate::{
BuildError, CharsetPolicy, Locate, Location, MAX_ID_LEN, UnknownSchema, ValidationError,
WriteError, check_amount, check_id, check_name, truncate_chars,
};
use crate::{Bic, Iban, IsoDateTime, ct_to_eur_str};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DirectDebitSchema {
#[default]
IsoV8,
IsoV2,
DkV2_7,
}
impl DirectDebitSchema {
pub const ALL: &'static [Self] = &[Self::IsoV8, Self::IsoV2, Self::DkV2_7];
#[must_use]
pub const fn message_id(self) -> &'static str {
match self {
Self::IsoV8 => "pain.008.001.08",
Self::IsoV2 => "pain.008.001.02",
Self::DkV2_7 => "pain.008.003.02",
}
}
#[must_use]
pub const fn namespace(self) -> &'static str {
match self {
Self::IsoV8 => "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08",
Self::IsoV2 => "urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
Self::DkV2_7 => "urn:iso:std:iso:20022:tech:xsd:pain.008.003.02",
}
}
#[must_use]
pub const fn supports_postal_address(self) -> bool {
!matches!(self, Self::DkV2_7)
}
#[must_use]
pub const fn bic_pattern(self) -> BicPattern {
match self {
Self::IsoV8 => BicPattern::Alphanumeric,
Self::IsoV2 | Self::DkV2_7 => BicPattern::LettersOnly,
}
}
#[must_use]
const fn bic_element(self) -> &'static str {
match self {
Self::IsoV8 => "BICFI",
Self::IsoV2 | Self::DkV2_7 => "BIC",
}
}
#[must_use]
const fn smnda_in_original_agent(self) -> bool {
matches!(self, Self::DkV2_7)
}
}
impl std::fmt::Display for DirectDebitSchema {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message_id())
}
}
impl FromStr for DirectDebitSchema {
type Err = UnknownSchema;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let key = s.trim().to_ascii_lowercase();
Self::ALL
.iter()
.copied()
.find(|schema| key == schema.message_id() || key == schema.namespace())
.ok_or_else(|| UnknownSchema {
value: s.to_owned(),
supported: "pain.008.001.08, pain.008.001.02, pain.008.003.02",
})
}
}
impl TryFrom<&str> for DirectDebitSchema {
type Error = UnknownSchema;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DirectDebitScheme {
#[default]
Core,
B2b,
}
impl DirectDebitScheme {
#[inline]
#[must_use]
pub const fn as_code(self) -> &'static str {
match self {
Self::Core => "CORE",
Self::B2b => "B2B",
}
}
}
impl std::fmt::Display for DirectDebitScheme {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown sequence type {0:?}: expected FRST, RCUR, FNAL, or OOFF")]
pub struct UnknownSequenceType(
pub String,
);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum SequenceType {
Frst,
#[default]
Rcur,
Fnal,
Ooff,
}
impl SequenceType {
#[inline]
#[must_use]
pub const fn as_code(self) -> &'static str {
match self {
Self::Frst => "FRST",
Self::Rcur => "RCUR",
Self::Fnal => "FNAL",
Self::Ooff => "OOFF",
}
}
}
impl std::fmt::Display for SequenceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
impl FromStr for SequenceType {
type Err = UnknownSequenceType;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_uppercase().as_str() {
"FRST" => Ok(Self::Frst),
"RCUR" => Ok(Self::Rcur),
"FNAL" => Ok(Self::Fnal),
"OOFF" => Ok(Self::Ooff),
_ => Err(UnknownSequenceType(s.to_owned())),
}
}
}
impl TryFrom<&str> for SequenceType {
type Error = UnknownSequenceType;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MandateAmendment {
pub original_mandate_id: Option<String>,
pub original_creditor_name: Option<String>,
pub original_creditor_id: Option<CreditorId>,
pub original_debtor_iban: Option<Iban>,
pub same_mandate_new_account: bool,
}
impl MandateAmendment {
#[must_use]
pub fn debtor_account_changed() -> Self {
Self {
same_mandate_new_account: true,
..Self::default()
}
}
#[must_use]
pub fn debtor_iban_changed(previous: Iban) -> Self {
Self {
original_debtor_iban: Some(previous),
..Self::default()
}
}
#[must_use]
pub fn creditor_id_changed(previous: CreditorId) -> Self {
Self {
original_creditor_id: Some(previous),
..Self::default()
}
}
pub fn creditor_name_changed(previous: impl Into<String>) -> Self {
Self {
original_creditor_name: Some(previous.into()),
..Self::default()
}
}
pub fn mandate_id_changed(previous: impl Into<String>) -> Self {
Self {
original_mandate_id: Some(previous.into()),
..Self::default()
}
}
#[must_use]
pub fn with_original_mandate_id(mut self, previous: impl Into<String>) -> Self {
self.original_mandate_id = Some(previous.into());
self
}
#[must_use]
pub fn with_original_creditor_name(mut self, previous: impl Into<String>) -> Self {
self.original_creditor_name = Some(previous.into());
self
}
pub fn validate(&self, charset: CharsetPolicy) -> Result<(), ValidationError> {
if self.original_mandate_id.is_none()
&& self.original_creditor_name.is_none()
&& self.original_creditor_id.is_none()
&& self.original_debtor_iban.is_none()
&& !self.same_mandate_new_account
{
return Err(ValidationError::Empty {
field: "MndtRltdInf/AmdmntInfDtls",
});
}
if self.same_mandate_new_account && self.original_debtor_iban.is_some() {
return Err(ValidationError::MutuallyExclusive {
field: "AmdmntInfDtls/OrgnlDbtrAcct",
first: "SMNDA",
second: "OrgnlDbtrAcct/Id/IBAN",
});
}
if let Some(id) = &self.original_mandate_id {
check_id("AmdmntInfDtls/OrgnlMndtId", id)?;
}
if let Some(name) = &self.original_creditor_name {
check_name(
"OrgnlCdtrSchmeId/Nm",
&charset.apply("OrgnlCdtrSchmeId/Nm", name)?,
)?;
}
Ok(())
}
fn write_xml<W: std::fmt::Write>(
&self,
w: &mut W,
charset: CharsetPolicy,
schema: DirectDebitSchema,
) -> std::fmt::Result {
use crate::xml_util::write_escaped;
w.write_str(" <AmdmntInd>true</AmdmntInd>\n")?;
w.write_str(" <AmdmntInfDtls>")?;
if let Some(id) = &self.original_mandate_id {
w.write_str("<OrgnlMndtId>")?;
write_escaped(w, id)?;
w.write_str("</OrgnlMndtId>")?;
}
if self.original_creditor_name.is_some() || self.original_creditor_id.is_some() {
w.write_str("<OrgnlCdtrSchmeId>")?;
if let Some(name) = &self.original_creditor_name {
let name = charset.render(name);
w.write_str("<Nm>")?;
write_escaped(w, &name)?;
w.write_str("</Nm>")?;
}
if let Some(ci) = &self.original_creditor_id {
w.write_str("<Id><PrvtId><Othr><Id>")?;
w.write_str(ci.as_str())?;
w.write_str("</Id><SchmeNm><Prtry>SEPA</Prtry></SchmeNm></Othr></PrvtId></Id>")?;
}
w.write_str("</OrgnlCdtrSchmeId>")?;
}
if self.same_mandate_new_account {
if schema.smnda_in_original_agent() {
w.write_str(
"<OrgnlDbtrAgt><FinInstnId><Othr><Id>SMNDA</Id></Othr></FinInstnId></OrgnlDbtrAgt>",
)?;
} else {
w.write_str("<OrgnlDbtrAcct><Id><Othr><Id>SMNDA</Id></Othr></Id></OrgnlDbtrAcct>")?;
}
} else if let Some(iban) = &self.original_debtor_iban {
w.write_str("<OrgnlDbtrAcct><Id><IBAN>")?;
w.write_str(iban.as_str())?;
w.write_str("</IBAN></Id></OrgnlDbtrAcct>")?;
}
w.write_str("</AmdmntInfDtls>\n")
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DirectDebitEntry {
pub mandate_ref: String,
pub mandate_signed_at: IsoDate,
pub debtor_name: String,
pub debtor_iban: Iban,
pub debtor_bic: Option<Bic>,
pub debtor_address: Option<PostalAddress>,
pub amount_ct: i64,
pub end_to_end_id: String,
pub remittance: Option<RemittanceInfo>,
pub ultimate_creditor: Option<Party>,
pub ultimate_debtor: Option<Party>,
pub purpose: Option<Purpose>,
pub amendment: Option<MandateAmendment>,
}
impl DirectDebitEntry {
pub fn new(
mandate_ref: impl Into<String>,
mandate_signed_at: IsoDate,
debtor_name: impl Into<String>,
debtor_iban: Iban,
amount_ct: i64,
end_to_end_id: impl Into<String>,
) -> Self {
Self {
mandate_ref: mandate_ref.into(),
mandate_signed_at,
debtor_name: debtor_name.into(),
debtor_iban,
amount_ct,
end_to_end_id: end_to_end_id.into(),
debtor_bic: None,
debtor_address: None,
remittance: None,
ultimate_creditor: None,
ultimate_debtor: None,
purpose: None,
amendment: None,
}
}
#[must_use]
pub fn with_bic(mut self, bic: Bic) -> Self {
self.debtor_bic = Some(bic);
self
}
#[must_use]
pub fn with_debtor_address(mut self, address: PostalAddress) -> Self {
self.debtor_address = Some(address);
self
}
#[must_use]
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.remittance = Some(RemittanceInfo::Unstructured(desc.into()));
self
}
#[must_use]
pub fn with_reference(mut self, reference: crate::RfReference) -> Self {
self.remittance = Some(RemittanceInfo::Structured(reference));
self
}
#[must_use]
pub fn with_remittance(mut self, remittance: RemittanceInfo) -> Self {
self.remittance = Some(remittance);
self
}
#[must_use]
pub fn with_ultimate_creditor(mut self, party: impl Into<Party>) -> Self {
self.ultimate_creditor = Some(party.into());
self
}
#[must_use]
pub fn with_ultimate_debtor(mut self, party: impl Into<Party>) -> Self {
self.ultimate_debtor = Some(party.into());
self
}
#[must_use]
pub fn with_purpose(mut self, purpose: Purpose) -> Self {
self.purpose = Some(purpose);
self
}
#[must_use]
pub fn with_amendment(mut self, amendment: MandateAmendment) -> Self {
self.amendment = Some(amendment);
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DirectDebitGroup {
payment_info_id: Option<String>,
pub(crate) creditor_name: String,
pub(crate) creditor_iban: Iban,
pub(crate) creditor_bic: Option<Bic>,
creditor_address: Option<PostalAddress>,
pub(crate) creditor_id: CreditorId,
pub(crate) sequence_type: SequenceType,
pub(crate) scheme: DirectDebitScheme,
pub(crate) collection_date: IsoDate,
batch_booking: Option<bool>,
category_purpose: Option<CategoryPurpose>,
ultimate_creditor: Option<Party>,
entries: Vec<DirectDebitEntry>,
}
impl DirectDebitGroup {
pub fn new(
creditor_name: impl Into<String>,
creditor_iban: &Iban,
creditor_id: &CreditorId,
collection_date: IsoDate,
) -> Self {
Self {
payment_info_id: None,
creditor_name: creditor_name.into(),
creditor_iban: creditor_iban.clone(),
creditor_bic: None,
creditor_address: None,
creditor_id: creditor_id.clone(),
sequence_type: SequenceType::Rcur,
scheme: DirectDebitScheme::Core,
collection_date,
batch_booking: None,
category_purpose: None,
ultimate_creditor: None,
entries: Vec::new(),
}
}
#[must_use]
pub fn payment_info_id(mut self, id: impl Into<String>) -> Self {
self.payment_info_id = Some(id.into());
self
}
#[must_use]
pub fn sequence_type(mut self, st: SequenceType) -> Self {
self.sequence_type = st;
self
}
#[must_use]
pub fn scheme(mut self, scheme: DirectDebitScheme) -> Self {
self.scheme = scheme;
self
}
#[must_use]
pub const fn requested_collection_date(&self) -> IsoDate {
self.collection_date
}
#[must_use]
pub fn creditor_bic(mut self, bic: Bic) -> Self {
self.creditor_bic = Some(bic);
self
}
#[must_use]
pub fn creditor_address(mut self, address: PostalAddress) -> Self {
self.creditor_address = Some(address);
self
}
#[must_use]
pub fn batch_booking(mut self, batch: bool) -> Self {
self.batch_booking = Some(batch);
self
}
#[must_use]
pub fn category_purpose(mut self, purpose: CategoryPurpose) -> Self {
self.category_purpose = Some(purpose);
self
}
#[must_use]
pub fn ultimate_creditor(mut self, party: impl Into<Party>) -> Self {
self.ultimate_creditor = Some(party.into());
self
}
#[must_use]
pub fn add_entry(mut self, entry: DirectDebitEntry) -> Self {
self.entries.push(entry);
self
}
#[must_use]
pub fn add_entries(mut self, entries: impl IntoIterator<Item = DirectDebitEntry>) -> 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.amount_ct))
}
}
#[derive(Debug, Clone)]
pub struct Pain008Builder {
initiating_party: String,
msg_id: String,
created_at: Option<IsoDateTime>,
schema: DirectDebitSchema,
charset: CharsetPolicy,
groups: Vec<DirectDebitGroup>,
}
impl Pain008Builder {
pub fn new(initiating_party: impl Into<String>, msg_id: impl Into<String>) -> Self {
Self {
initiating_party: initiating_party.into(),
msg_id: msg_id.into(),
created_at: None,
schema: DirectDebitSchema::default(),
charset: CharsetPolicy::default(),
groups: Vec::new(),
}
}
#[must_use]
pub fn created_at(mut self, timestamp: IsoDateTime) -> Self {
self.created_at = Some(timestamp);
self
}
#[must_use]
pub fn schema(mut self, schema: DirectDebitSchema) -> Self {
self.schema = schema;
self
}
#[must_use]
pub fn charset(mut self, policy: CharsetPolicy) -> Self {
self.charset = policy;
self
}
#[must_use]
pub fn add_group(mut self, group: DirectDebitGroup) -> Self {
self.groups.push(group);
self
}
#[must_use]
pub fn message_id(&self) -> &str {
&self.msg_id
}
#[must_use]
pub const fn schema_version(&self) -> DirectDebitSchema {
self.schema
}
#[must_use]
pub const fn creation_timestamp(&self) -> Option<IsoDateTime> {
self.created_at
}
#[must_use]
pub fn group_count(&self) -> usize {
self.groups.len()
}
#[must_use]
pub fn entry_count(&self) -> usize {
self.groups.iter().map(DirectDebitGroup::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 payment_info_id(&self, index: usize) -> String {
if let Some(id) = self
.groups
.get(index)
.and_then(|g| g.payment_info_id.clone())
{
return id;
}
if self.groups.len() <= 1 {
return self.msg_id.clone();
}
let suffix = format!("-{}", index + 1);
let keep = MAX_ID_LEN.saturating_sub(suffix.chars().count());
format!("{}{suffix}", truncate_chars(&self.msg_id, keep))
}
fn check_bic_supported(&self, field: &'static str, bic: &Bic) -> Result<(), ValidationError> {
let pattern = self.schema.bic_pattern();
if bic.fits(pattern) {
Ok(())
} else {
Err(ValidationError::SchemaPattern {
field,
value: bic.as_str().to_owned(),
schema: self.schema.message_id(),
expected: pattern.as_xsd_pattern(),
})
}
}
fn check_address_supported(&self, feature: &'static str) -> Result<(), ValidationError> {
if self.schema.supports_postal_address() {
Ok(())
} else {
Err(ValidationError::UnsupportedBySchema {
feature,
schema: self.schema.message_id(),
})
}
}
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_name(
"InitgPty/Nm",
&self
.charset
.apply("InitgPty/Nm", &self.initiating_party)
.at(msg)?,
)
.at(msg)?;
let mut total: i64 = 0;
let mut seen_ids = std::collections::BTreeSet::new();
for (i, g) in self.groups.iter().enumerate() {
self.validate_group(i, g, &mut seen_ids, &mut total)?;
}
Ok(())
}
fn validate_group(
&self,
i: usize,
g: &DirectDebitGroup,
seen_ids: &mut std::collections::BTreeSet<String>,
total: &mut i64,
) -> Result<(), BuildError> {
let at = Location::group(i);
if g.entries.is_empty() {
return Err(BuildError::group(i, ValidationError::EmptyBatch));
}
let id = self.payment_info_id(i);
check_id("PmtInf/PmtInfId", &id).at(at)?;
if !seen_ids.insert(id.clone()) {
return Err(BuildError::group(
i,
ValidationError::Duplicate {
field: "PmtInf/PmtInfId",
value: id,
},
));
}
check_name(
"Cdtr/Nm",
&self.charset.apply("Cdtr/Nm", &g.creditor_name).at(at)?,
)
.at(at)?;
if let Some(bic) = &g.creditor_bic {
self.check_bic_supported("CdtrAgt/FinInstnId", bic).at(at)?;
}
if let Some(a) = &g.creditor_address {
self.check_address_supported("Cdtr/PstlAdr").at(at)?;
a.validate(self.charset).at(at)?;
}
if let Some(p) = &g.category_purpose {
p.validate("PmtTpInf/CtgyPurp/Cd").at(at)?;
}
if let Some(p) = &g.ultimate_creditor {
p.validate("PmtInf/UltmtCdtr", self.charset).at(at)?;
}
for (j, e) in g.entries.iter().enumerate() {
self.validate_entry(Location::transaction(i, j), g, e)?;
*total = total.checked_add(e.amount_ct).ok_or(BuildError {
location: Location::transaction(i, j),
kind: ValidationError::ControlSumOverflow,
})?;
}
Ok(())
}
fn validate_entry(
&self,
at: Location,
g: &DirectDebitGroup,
e: &DirectDebitEntry,
) -> Result<(), BuildError> {
if g.ultimate_creditor.is_some() && e.ultimate_creditor.is_some() {
return Err(BuildError {
location: at,
kind: ValidationError::ConflictingLevels { field: "UltmtCdtr" },
});
}
check_id("DrctDbtTxInf/PmtId/EndToEndId", &e.end_to_end_id).at(at)?;
check_id("MndtRltdInf/MndtId", &e.mandate_ref).at(at)?;
if e.mandate_signed_at > g.collection_date {
return Err(BuildError {
location: at,
kind: ValidationError::DateOrder {
later: "MndtRltdInf/DtOfSgntr",
later_value: e.mandate_signed_at,
earlier: "PmtInf/ReqdColltnDt",
earlier_value: g.collection_date,
},
});
}
check_amount("DrctDbtTxInf/InstdAmt", e.amount_ct).at(at)?;
check_name(
"Dbtr/Nm",
&self.charset.apply("Dbtr/Nm", &e.debtor_name).at(at)?,
)
.at(at)?;
if let Some(bic) = &e.debtor_bic {
self.check_bic_supported("DbtrAgt/FinInstnId", bic).at(at)?;
}
if let Some(a) = &e.debtor_address {
self.check_address_supported("Dbtr/PstlAdr").at(at)?;
a.validate(self.charset).at(at)?;
}
if let Some(p) = &e.ultimate_creditor {
p.validate("DrctDbtTxInf/UltmtCdtr", self.charset).at(at)?;
}
if let Some(p) = &e.ultimate_debtor {
p.validate("DrctDbtTxInf/UltmtDbtr", self.charset).at(at)?;
}
if let Some(p) = &e.purpose {
p.validate("DrctDbtTxInf/Purp/Cd").at(at)?;
}
if let Some(a) = &e.amendment {
a.validate(self.charset).at(at)?;
}
if let Some(r) = &e.remittance {
r.validate(crate::pain001::remittance_field(r), self.charset)
.at(at)?;
}
Ok(())
}
pub fn build(&self) -> Result<String, BuildError> {
self.validate()?;
let mut buf = String::with_capacity(900 + self.entry_count() * 480);
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 {
use crate::xml_util::write_escaped;
let now = self.created_at.unwrap_or_else(IsoDateTime::now);
let namespace = self.schema.namespace();
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(" <CstmrDrctDbtInitn>\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 </GrpHdr>\n")?;
for (i, g) in self.groups.iter().enumerate() {
self.write_group(w, g, &self.payment_info_id(i))?;
}
w.write_str(" </CstmrDrctDbtInitn>\n</Document>")
}
fn write_group<W: std::fmt::Write>(
&self,
w: &mut W,
g: &DirectDebitGroup,
payment_info_id: &str,
) -> std::fmt::Result {
use crate::xml_util::write_escaped;
let bic_el = self.schema.bic_element();
let creditor_name = self.charset.render(&g.creditor_name);
w.write_str(" <PmtInf>\n <PmtInfId>")?;
write_escaped(w, payment_info_id)?;
w.write_str("</PmtInfId>\n <PmtMtd>DD</PmtMtd>\n")?;
if let Some(batch) = g.batch_booking {
writeln!(w, " <BtchBookg>{batch}</BtchBookg>")?;
}
write!(
w,
" <NbOfTxs>{}</NbOfTxs>\n <CtrlSum>{}</CtrlSum>\n",
g.entry_count(),
ct_to_eur_str(g.total_ct())
)?;
w.write_str(" <PmtTpInf>\n <SvcLvl><Cd>SEPA</Cd></SvcLvl>\n")?;
writeln!(
w,
" <LclInstrm><Cd>{}</Cd></LclInstrm>",
g.scheme.as_code()
)?;
writeln!(w, " <SeqTp>{}</SeqTp>", g.sequence_type.as_code())?;
if let Some(p) = &g.category_purpose {
writeln!(w, " <CtgyPurp><Cd>{}</Cd></CtgyPurp>", p.as_code())?;
}
w.write_str(" </PmtTpInf>\n")?;
writeln!(
w,
" <ReqdColltnDt>{}</ReqdColltnDt>",
g.collection_date
)?;
w.write_str(" <Cdtr><Nm>")?;
write_escaped(w, &creditor_name)?;
w.write_str("</Nm>")?;
if let Some(address) = &g.creditor_address {
address.write_xml(w, self.charset)?;
}
w.write_str("</Cdtr>\n")?;
writeln!(
w,
" <CdtrAcct><Id><IBAN>{}</IBAN></Id></CdtrAcct>",
g.creditor_iban.as_str()
)?;
w.write_str(" <CdtrAgt><FinInstnId>")?;
match &g.creditor_bic {
Some(bic) => write!(w, "<{bic_el}>{}</{bic_el}>", bic.as_str())?,
None => w.write_str("<Othr><Id>NOTPROVIDED</Id></Othr>")?,
}
w.write_str("</FinInstnId></CdtrAgt>\n")?;
if let Some(p) = &g.ultimate_creditor {
p.write_xml(w, "UltmtCdtr", " ", self.charset)?;
}
w.write_str(" <ChrgBr>SLEV</ChrgBr>\n")?;
w.write_str(" <CdtrSchmeId><Id><PrvtId><Othr><Id>")?;
w.write_str(g.creditor_id.as_str())?;
w.write_str(
"</Id><SchmeNm><Prtry>SEPA</Prtry></SchmeNm></Othr></PrvtId></Id></CdtrSchmeId>\n",
)?;
for entry in &g.entries {
self.write_transaction(w, entry)?;
}
w.write_str(" </PmtInf>\n")
}
fn write_transaction<W: std::fmt::Write>(
&self,
w: &mut W,
e: &DirectDebitEntry,
) -> std::fmt::Result {
use crate::xml_util::{write_escaped, write_eur};
let bic_el = self.schema.bic_element();
let debtor_name = self.charset.render(&e.debtor_name);
w.write_str(" <DrctDbtTxInf>\n <PmtId>\n <EndToEndId>")?;
write_escaped(w, &e.end_to_end_id)?;
w.write_str("</EndToEndId>\n </PmtId>\n <InstdAmt Ccy=\"EUR\">")?;
write_eur(w, e.amount_ct)?;
w.write_str("</InstdAmt>\n <DrctDbtTx>\n <MndtRltdInf>\n <MndtId>")?;
write_escaped(w, &e.mandate_ref)?;
w.write_str("</MndtId>\n <DtOfSgntr>")?;
write!(w, "{}", e.mandate_signed_at)?;
w.write_str("</DtOfSgntr>\n")?;
if let Some(amendment) = &e.amendment {
amendment.write_xml(w, self.charset, self.schema)?;
}
w.write_str(" </MndtRltdInf>\n </DrctDbtTx>\n")?;
if let Some(p) = &e.ultimate_creditor {
p.write_xml(w, "UltmtCdtr", " ", self.charset)?;
}
w.write_str(" <DbtrAgt><FinInstnId>")?;
match &e.debtor_bic {
Some(bic) => write!(w, "<{bic_el}>{}</{bic_el}>", bic.as_str())?,
None => w.write_str("<Othr><Id>NOTPROVIDED</Id></Othr>")?,
}
w.write_str("</FinInstnId></DbtrAgt>\n <Dbtr><Nm>")?;
write_escaped(w, &debtor_name)?;
w.write_str("</Nm>")?;
if let Some(address) = &e.debtor_address {
address.write_xml(w, self.charset)?;
}
w.write_str("</Dbtr>\n <DbtrAcct><Id><IBAN>")?;
w.write_str(e.debtor_iban.as_str())?;
w.write_str("</IBAN></Id></DbtrAcct>\n")?;
if let Some(p) = &e.ultimate_debtor {
p.write_xml(w, "UltmtDbtr", " ", self.charset)?;
}
if let Some(purpose) = &e.purpose {
writeln!(w, " <Purp><Cd>{}</Cd></Purp>", purpose.as_code())?;
}
if let Some(remittance) = &e.remittance {
remittance.write_xml(w, " ", self.charset)?;
}
w.write_str(" </DrctDbtTxInf>\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::iban::validate_iban;
use crate::validate::{CharsetPolicy, Location, ValidationError};
fn d(s: &str) -> IsoDate {
s.parse().unwrap()
}
fn de_iban() -> Iban {
validate_iban("DE89370400440532013000").unwrap()
}
fn nl_iban() -> Iban {
validate_iban("NL91ABNA0417164300").unwrap()
}
fn ci() -> CreditorId {
crate::validate_creditor_id("DE98ZZZ09999999999").unwrap()
}
fn entry(mandate: &str, amount_ct: i64) -> DirectDebitEntry {
DirectDebitEntry::new(
mandate,
d("2024-01-01"),
"Max Mustermann",
nl_iban(),
amount_ct,
mandate,
)
}
fn group(name: &str) -> DirectDebitGroup {
group_on(name, d("2026-07-20"))
}
fn group_on(name: &str, date: IsoDate) -> DirectDebitGroup {
DirectDebitGroup::new(name, &de_iban(), &ci(), date)
}
fn one_group(name: &str) -> Pain008Builder {
Pain008Builder::new(name, "DD-001")
.add_group(group(name).add_entry(entry("MND-001", 7_500)))
}
#[test]
fn basic_structure() {
let xml = one_group("Test GmbH").build().unwrap();
assert!(xml.contains("urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"));
assert!(xml.contains("<MsgId>DD-001</MsgId>"));
assert!(xml.contains("<PmtMtd>DD</PmtMtd>"));
assert!(xml.contains("<InstdAmt Ccy=\"EUR\">75.00</InstdAmt>"));
assert!(xml.contains("<SeqTp>RCUR</SeqTp>"));
assert!(xml.contains("<Cd>CORE</Cd>"));
assert!(xml.contains("<ChrgBr>SLEV</ChrgBr>"));
assert!(xml.contains("<CdtrSchmeId>"));
assert!(xml.contains("<ReqdColltnDt>2026-07-20</ReqdColltnDt>"));
}
#[test]
fn first_and_recurring_collections_fit_in_one_file() {
let xml = Pain008Builder::new("Stadtwerke GmbH", "DD-RUN")
.add_group(
group_on("Stadtwerke GmbH", d("2026-07-20"))
.sequence_type(SequenceType::Frst)
.add_entry(entry("MND-NEW", 5_000)),
)
.add_group(
group_on("Stadtwerke GmbH", d("2026-07-18"))
.sequence_type(SequenceType::Rcur)
.add_entry(entry("MND-OLD", 7_500))
.add_entry(entry("MND-OLD-2", 2_500)),
)
.build()
.unwrap();
assert_eq!(xml.matches("<PmtInf>").count(), 2);
assert!(xml.contains("<SeqTp>FRST</SeqTp>"));
assert!(xml.contains("<SeqTp>RCUR</SeqTp>"));
assert!(xml.contains("<ReqdColltnDt>2026-07-20</ReqdColltnDt>"));
assert!(xml.contains("<ReqdColltnDt>2026-07-18</ReqdColltnDt>"));
assert!(xml.contains("<NbOfTxs>3</NbOfTxs>"));
assert!(xml.contains("<CtrlSum>150.00</CtrlSum>"));
}
#[test]
fn core_and_b2b_can_coexist_in_one_file() {
let xml = Pain008Builder::new("Test", "DD-MIX")
.add_group(
group("Test")
.scheme(DirectDebitScheme::Core)
.add_entry(entry("M1", 1_000)),
)
.add_group(
group("Test")
.scheme(DirectDebitScheme::B2b)
.add_entry(entry("M2", 2_000)),
)
.build()
.unwrap();
assert!(xml.contains("<Cd>CORE</Cd>"));
assert!(xml.contains("<Cd>B2B</Cd>"));
}
#[test]
fn postal_addresses_sit_inside_the_party_after_the_name() {
let xml = Pain008Builder::new("Test", "DD-ADR")
.add_group(
group("Stadtwerke GmbH")
.creditor_address(
crate::PostalAddress::new("Berlin", "DE")
.unwrap()
.street("Hauptstrasse")
.building_number("1"),
)
.add_entry(
entry("M1", 1_000).with_debtor_address(
crate::PostalAddress::new("Wien", "AT")
.unwrap()
.post_code("1010"),
),
),
)
.build()
.unwrap();
assert!(xml.contains(
"<Cdtr><Nm>Stadtwerke GmbH</Nm><PstlAdr><StrtNm>Hauptstrasse</StrtNm>\
<BldgNb>1</BldgNb><TwnNm>Berlin</TwnNm><Ctry>DE</Ctry></PstlAdr></Cdtr>"
));
assert!(xml.contains(
"<Dbtr><Nm>Max Mustermann</Nm><PstlAdr><PstCd>1010</PstCd>\
<TwnNm>Wien</TwnNm><Ctry>AT</Ctry></PstlAdr></Dbtr>"
));
}
#[test]
fn an_address_on_a_schema_without_one_is_rejected() {
let err = Pain008Builder::new("Test", "DD-DK-ADR")
.schema(DirectDebitSchema::DkV2_7)
.add_group(
group("Test").add_entry(
entry("M1", 100)
.with_debtor_address(crate::PostalAddress::new("Wien", "AT").unwrap()),
),
)
.build()
.unwrap_err();
assert_eq!(
err.kind,
ValidationError::UnsupportedBySchema {
feature: "Dbtr/PstlAdr",
schema: "pain.008.003.02",
}
);
assert_eq!(err.location, Location::transaction(0, 0));
}
#[test]
fn creditor_scheme_id_is_emitted_per_group() {
let xml = one_group("Test").build().unwrap();
assert!(xml.contains("DE98ZZZ09999999999"));
assert!(xml.contains("<Prtry>SEPA</Prtry>"));
}
#[test]
fn a_mandate_signed_after_the_collection_it_authorises_is_rejected() {
let collect = d("2026-07-20");
let build = |signed| {
Pain008Builder::new("Acme", "DD-1")
.add_group(
DirectDebitGroup::new("Acme", &de_iban(), &ci(), collect).add_entry(
DirectDebitEntry::new("MND-1", signed, "Max", nl_iban(), 1_000, "E2E-1"),
),
)
.build()
};
assert!(build(d("2026-07-20")).is_ok(), "signed on the day is fine");
assert!(build(d("2019-01-01")).is_ok());
let err = build(d("2026-07-21")).unwrap_err();
assert_eq!(err.location, Location::transaction(0, 0));
assert!(matches!(
err.kind,
ValidationError::DateOrder {
later: "MndtRltdInf/DtOfSgntr",
earlier: "PmtInf/ReqdColltnDt",
..
}
));
assert_eq!(
err.to_string(),
"PmtInf[0]/Tx[0]: MndtRltdInf/DtOfSgntr 2026-07-21 must not be after \
PmtInf/ReqdColltnDt 2026-07-20",
);
}
#[test]
fn a_modern_bic_is_refused_by_the_pre_2019_schemas_and_named() {
let modern = crate::validate_bic("E097DEFF").unwrap();
let build = |schema| {
Pain008Builder::new("Acme", "DD-1")
.schema(schema)
.add_group(
DirectDebitGroup::new("Acme", &de_iban(), &ci(), d("2026-07-20"))
.creditor_bic(modern.clone())
.add_entry(entry("MND-1", 1_000)),
)
.build()
};
assert!(build(DirectDebitSchema::IsoV8).is_ok(), "BICFI holds it");
for legacy in [DirectDebitSchema::IsoV2, DirectDebitSchema::DkV2_7] {
let err = build(legacy).unwrap_err();
assert!(
matches!(
&err.kind,
ValidationError::SchemaPattern { value, expected, .. }
if value == "E097DEFF"
&& *expected == BicPattern::LettersOnly.as_xsd_pattern()
),
"{legacy} must refuse it by name, got {err:?}"
);
}
}
#[test]
fn legacy_dk_schema_uses_bic_element() {
let xml = Pain008Builder::new("Test", "DD-DK")
.schema(DirectDebitSchema::DkV2_7)
.add_group(
group("Test")
.creditor_bic("COBADEFF".parse().unwrap())
.add_entry(entry("M1", 1_000)),
)
.build()
.unwrap();
assert!(xml.contains("pain.008.003.02"));
assert!(xml.contains("<BIC>COBADEFF</BIC>"));
assert!(!xml.contains("BICFI"));
}
#[test]
fn iban_only_agents_use_othr_not_a_fake_bic() {
let xml = one_group("Test").build().unwrap();
assert!(xml.contains("<CdtrAgt><FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr>"));
assert!(xml.contains("<DbtrAgt><FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr>"));
assert!(!xml.contains("NOTPROVIDED</BIC"));
}
#[test]
fn mandate_amendment_emits_smnda_in_its_current_position() {
let xml = Pain008Builder::new("Test", "DD-AMD")
.add_group(group("Test").add_entry(
entry("M1", 1_000).with_amendment(MandateAmendment::debtor_account_changed()),
))
.build()
.unwrap();
assert!(xml.contains("<AmdmntInd>true</AmdmntInd>"));
assert!(
xml.contains("<OrgnlDbtrAcct><Id><Othr><Id>SMNDA</Id></Othr></Id></OrgnlDbtrAcct>")
);
assert!(!xml.contains("OrgnlDbtrAgt"));
}
#[test]
fn an_amendment_with_no_detail_is_rejected() {
assert!(matches!(
Pain008Builder::new("Test", "DD-AMD")
.add_group(
group("Test")
.add_entry(entry("M1", 1_000).with_amendment(MandateAmendment::default()),)
)
.build()
.unwrap_err()
.kind,
ValidationError::Empty { .. }
));
}
#[test]
fn an_amendment_cannot_both_state_and_suppress_the_previous_account() {
let amendment = MandateAmendment {
original_debtor_iban: Some(nl_iban()),
same_mandate_new_account: true,
..MandateAmendment::default()
};
assert_eq!(
Pain008Builder::new("Test", "DD-AMD")
.add_group(group("Test").add_entry(entry("M1", 100).with_amendment(amendment)))
.build()
.unwrap_err()
.kind,
ValidationError::MutuallyExclusive {
field: "AmdmntInfDtls/OrgnlDbtrAcct",
first: "SMNDA",
second: "OrgnlDbtrAcct/Id/IBAN",
}
);
for a in [
MandateAmendment::debtor_account_changed(),
MandateAmendment::debtor_iban_changed(nl_iban()),
] {
assert!(
Pain008Builder::new("Test", "DD-AMD")
.add_group(group("Test").add_entry(entry("M1", 100).with_amendment(a)))
.build()
.is_ok()
);
}
}
#[test]
fn ultimate_creditor_cannot_be_set_at_both_levels() {
let b = Pain008Builder::new("Test", "DD-ULT").add_group(
group("Test")
.ultimate_creditor(Party::new("Gruppe"))
.add_entry(entry("M1", 100).with_ultimate_creditor(Party::new("Transaktion"))),
);
assert_eq!(
b.build(),
Err(BuildError::transaction(
0,
0,
ValidationError::ConflictingLevels { field: "UltmtCdtr" }
))
);
}
#[test]
fn identifiers_are_rejected_rather_than_transliterated() {
assert!(matches!(
Pain008Builder::new("Test", "DD-1")
.add_group(group("Test").add_entry(DirectDebitEntry::new(
"MND-Ü",
d("2024-01-01"),
"Max",
nl_iban(),
100,
"E2E"
)))
.build(),
Err(BuildError {
location: Location {
group: Some(0),
transaction: Some(0)
},
kind: ValidationError::InvalidCharacter {
field: "MndtRltdInf/MndtId",
ch: 'Ü'
}
})
));
}
#[test]
fn empty_message_and_empty_group_are_both_rejected() {
assert_eq!(
Pain008Builder::new("Test", "E").build(),
Err(BuildError::message(ValidationError::EmptyBatch))
);
assert_eq!(
Pain008Builder::new("Test", "E")
.add_group(group("Test"))
.build(),
Err(BuildError::group(0, ValidationError::EmptyBatch))
);
}
#[test]
fn validation_rejects_bad_fields() {
let b = || Pain008Builder::new("Test", "OK");
let err = b()
.add_group(group("Test").add_entry(entry("M1", 0)))
.build()
.unwrap_err();
assert!(matches!(err.kind, ValidationError::AmountOutOfRange { .. }));
assert!(matches!(
Pain008Builder::new("Test", "X".repeat(36))
.add_group(group("Test").add_entry(entry("M1", 100)))
.build()
.unwrap_err()
.kind,
ValidationError::TooLong { .. }
));
}
#[test]
fn a_malformed_date_cannot_reach_a_batch() {
assert!("2026-02-30".parse::<IsoDate>().is_err());
assert!("01.06.2024".parse::<IsoDate>().is_err());
}
#[test]
fn errors_name_the_group_and_transaction_that_failed() {
let err = Pain008Builder::new("Test", "DD-LOC")
.add_group(group("Test").add_entry(entry("M1", 100)))
.add_group(
group("Test")
.add_entry(entry("M2", 100))
.add_entry(entry("M3", 0)),
)
.build()
.unwrap_err();
assert_eq!(err.location, Location::transaction(1, 1));
assert!(err.to_string().starts_with("PmtInf[1]/Tx[1]: "));
}
#[test]
fn totals_use_integer_arithmetic() {
let b = Pain008Builder::new("Test", "DD").add_group(
group("Test")
.add_entry(entry("M1", 10))
.add_entry(entry("M2", 20)),
);
assert_eq!(b.total_ct(), 30);
assert!(b.build().unwrap().contains("<CtrlSum>0.30</CtrlSum>"));
}
#[test]
fn non_sepa_characters_are_transliterated() {
let xml = Pain008Builder::new("Müller & Söhne GmbH", "DD-UML")
.add_group(
DirectDebitGroup::new("Müller & Söhne GmbH", &de_iban(), &ci(), d("2026-07-20"))
.add_entry(
DirectDebitEntry::new(
"MND-001",
d("2024-01-01"),
"Jörg Groß",
nl_iban(),
100,
"E2E-1",
)
.with_description("Abschlag für Straße 1"),
),
)
.build()
.unwrap();
assert!(xml.contains("Mueller + Soehne GmbH"));
assert!(xml.contains("Joerg Gross"));
assert!(xml.contains("Abschlag fuer Strasse 1"));
}
#[test]
fn sequence_type_parsing_and_display() {
assert_eq!("FRST".parse::<SequenceType>().unwrap(), SequenceType::Frst);
assert_eq!("rcur".parse::<SequenceType>().unwrap(), SequenceType::Rcur);
assert!("INVALID".parse::<SequenceType>().is_err());
assert_eq!(SequenceType::Ooff.to_string(), "OOFF");
assert_eq!(DirectDebitScheme::B2b.to_string(), "B2B");
}
#[test]
fn streaming_matches_the_in_memory_build() {
let make = || one_group("Test").created_at("2026-07-19T12:00:00".parse().unwrap());
let direct = make().build().unwrap();
let mut buf: Vec<u8> = Vec::new();
make().write_to(&mut buf).unwrap();
assert_eq!(direct, String::from_utf8(buf).unwrap());
}
#[test]
fn strict_charset_policy_rejects_umlauts_in_names() {
assert!(matches!(
one_group("Müller GmbH")
.charset(CharsetPolicy::Strict)
.build()
.unwrap_err()
.kind,
ValidationError::InvalidCharacter { ch: 'ü', .. }
));
}
#[test]
fn the_collection_date_is_required_and_never_defaulted() {
let group = group_on("Test", d("2026-07-20"));
assert_eq!(group.requested_collection_date(), d("2026-07-20"));
}
#[test]
fn schema_versions_round_trip_through_their_identifiers() {
for schema in DirectDebitSchema::ALL {
assert_eq!(
schema.message_id().parse::<DirectDebitSchema>(),
Ok(*schema)
);
assert_eq!(schema.namespace().parse::<DirectDebitSchema>(), Ok(*schema));
assert!(schema.namespace().ends_with(schema.message_id()));
}
assert!("pain.008.001.99".parse::<DirectDebitSchema>().is_err());
assert_eq!(
"PAIN.008.001.02".parse::<DirectDebitSchema>(),
Ok(DirectDebitSchema::IsoV2)
);
}
#[test]
fn the_epc_legacy_schema_uses_the_pre_2019_bic_element() {
let xml = Pain008Builder::new("Test", "DD-V2")
.schema(DirectDebitSchema::IsoV2)
.add_group(
group("Test")
.creditor_bic("COBADEFF".parse().unwrap())
.add_entry(entry("M1", 1_000)),
)
.build()
.unwrap();
assert!(xml.contains("pain.008.001.02"));
assert!(xml.contains("<BIC>COBADEFF</BIC>"));
assert!(!xml.contains("BICFI"));
}
#[test]
fn smnda_moves_to_the_original_agent_under_the_dk_schema() {
let build = |schema| {
Pain008Builder::new("Test", "DD-AMD")
.schema(schema)
.add_group(group("Test").add_entry(
entry("M1", 1_000).with_amendment(MandateAmendment::debtor_account_changed()),
))
.build()
.unwrap()
};
let dk = build(DirectDebitSchema::DkV2_7);
assert!(dk.contains(
"<OrgnlDbtrAgt><FinInstnId><Othr><Id>SMNDA</Id></Othr></FinInstnId></OrgnlDbtrAgt>"
));
assert!(!dk.contains("OrgnlDbtrAcct"));
for iso in [DirectDebitSchema::IsoV8, DirectDebitSchema::IsoV2] {
let xml = build(iso);
assert!(
xml.contains("<OrgnlDbtrAcct><Id><Othr><Id>SMNDA</Id></Othr></Id></OrgnlDbtrAcct>"),
"{iso} must use the post-2016 placement"
);
assert!(!xml.contains("OrgnlDbtrAgt"));
}
}
}