use std::str::FromStr;
use crate::address::PostalAddress;
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 CreditTransferSchema {
#[default]
IsoV9,
IsoV3,
DkV2_7,
}
impl CreditTransferSchema {
pub const ALL: &'static [Self] = &[Self::IsoV9, Self::IsoV3, Self::DkV2_7];
#[must_use]
pub const fn message_id(self) -> &'static str {
match self {
Self::IsoV9 => "pain.001.001.09",
Self::IsoV3 => "pain.001.001.03",
Self::DkV2_7 => "pain.001.003.03",
}
}
#[must_use]
pub const fn namespace(self) -> &'static str {
match self {
Self::IsoV9 => "urn:iso:std:iso:20022:tech:xsd:pain.001.001.09",
Self::IsoV3 => "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03",
Self::DkV2_7 => "urn:iso:std:iso:20022:tech:xsd:pain.001.003.03",
}
}
#[must_use]
pub const fn supports_local_instrument(self) -> bool {
!matches!(self, Self::DkV2_7)
}
#[must_use]
pub const fn supports_postal_address(self) -> bool {
!matches!(self, Self::DkV2_7)
}
#[must_use]
const fn bic_element(self) -> &'static str {
match self {
Self::IsoV9 => "BICFI",
Self::IsoV3 | Self::DkV2_7 => "BIC",
}
}
#[must_use]
pub const fn supports_execution_time(self) -> bool {
matches!(self, Self::IsoV9)
}
}
impl std::fmt::Display for CreditTransferSchema {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message_id())
}
}
impl FromStr for CreditTransferSchema {
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.001.001.09, pain.001.001.03, pain.001.003.03",
})
}
}
impl TryFrom<&str> for CreditTransferSchema {
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 LocalInstrument {
#[default]
None,
Inst,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ExecutionMoment {
On(IsoDate),
At(IsoDateTime),
}
impl ExecutionMoment {
#[must_use]
pub const fn date(self) -> IsoDate {
match self {
Self::On(date) => date,
Self::At(moment) => moment.date(),
}
}
#[must_use]
pub const fn is_timed(self) -> bool {
matches!(self, Self::At(_))
}
}
impl From<IsoDate> for ExecutionMoment {
fn from(date: IsoDate) -> Self {
Self::On(date)
}
}
impl From<IsoDateTime> for ExecutionMoment {
fn from(moment: IsoDateTime) -> Self {
Self::At(moment)
}
}
pub(crate) fn remittance_field(remittance: &RemittanceInfo) -> &'static str {
match remittance {
RemittanceInfo::Unstructured(_) => "RmtInf/Ustrd",
_ => "RmtInf/Strd",
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CreditTransferEntry {
pub creditor_name: String,
pub creditor_iban: Iban,
pub creditor_bic: Option<Bic>,
pub creditor_address: Option<PostalAddress>,
pub amount_ct: i64,
pub end_to_end_id: String,
pub remittance: Option<RemittanceInfo>,
pub ultimate_debtor: Option<Party>,
pub ultimate_creditor: Option<Party>,
pub purpose: Option<Purpose>,
}
impl CreditTransferEntry {
pub fn new(
creditor_name: impl Into<String>,
creditor_iban: Iban,
amount_ct: i64,
end_to_end_id: impl Into<String>,
) -> Self {
Self {
creditor_name: creditor_name.into(),
creditor_iban,
amount_ct,
end_to_end_id: end_to_end_id.into(),
creditor_bic: None,
creditor_address: None,
remittance: None,
ultimate_debtor: None,
ultimate_creditor: None,
purpose: None,
}
}
#[must_use]
pub fn with_bic(mut self, bic: Bic) -> Self {
self.creditor_bic = Some(bic);
self
}
#[must_use]
pub fn with_creditor_address(mut self, address: PostalAddress) -> Self {
self.creditor_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_debtor(mut self, party: impl Into<Party>) -> Self {
self.ultimate_debtor = Some(party.into());
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_purpose(mut self, purpose: Purpose) -> Self {
self.purpose = Some(purpose);
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CreditTransferGroup {
payment_info_id: Option<String>,
debtor_name: String,
debtor_iban: Iban,
debtor_bic: Option<Bic>,
debtor_address: Option<PostalAddress>,
execution: ExecutionMoment,
local_instrument: LocalInstrument,
batch_booking: Option<bool>,
category_purpose: Option<CategoryPurpose>,
ultimate_debtor: Option<Party>,
entries: Vec<CreditTransferEntry>,
}
impl CreditTransferGroup {
pub fn new(debtor_name: impl Into<String>, debtor_iban: &Iban) -> Self {
Self {
payment_info_id: None,
debtor_name: debtor_name.into(),
debtor_iban: debtor_iban.clone(),
debtor_bic: None,
debtor_address: None,
execution: ExecutionMoment::On(IsoDate::today()),
local_instrument: LocalInstrument::None,
batch_booking: None,
category_purpose: None,
ultimate_debtor: 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 execution_date(mut self, date: IsoDate) -> Self {
self.execution = ExecutionMoment::On(date);
self
}
#[must_use]
pub fn execution_at(mut self, moment: IsoDateTime) -> Self {
self.execution = ExecutionMoment::At(moment);
self
}
#[must_use]
pub const fn execution(&self) -> ExecutionMoment {
self.execution
}
#[must_use]
pub const fn requested_execution_date(&self) -> IsoDate {
self.execution.date()
}
#[must_use]
pub fn debtor_bic(mut self, bic: Bic) -> Self {
self.debtor_bic = Some(bic);
self
}
#[must_use]
pub fn debtor_address(mut self, address: PostalAddress) -> Self {
self.debtor_address = Some(address);
self
}
#[must_use]
pub fn local_instrument(mut self, li: LocalInstrument) -> Self {
self.local_instrument = li;
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_debtor(mut self, party: impl Into<Party>) -> Self {
self.ultimate_debtor = Some(party.into());
self
}
#[must_use]
pub fn add_entry(mut self, entry: CreditTransferEntry) -> Self {
self.entries.push(entry);
self
}
#[must_use]
pub fn add_entries(mut self, entries: impl IntoIterator<Item = CreditTransferEntry>) -> 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 Pain001Builder {
initiating_party: String,
msg_id: String,
created_at: Option<IsoDateTime>,
schema: CreditTransferSchema,
charset: CharsetPolicy,
groups: Vec<CreditTransferGroup>,
}
impl Pain001Builder {
pub fn new(initiating_party: impl Into<String>) -> Self {
Self {
initiating_party: initiating_party.into(),
msg_id: crate::pain008::default_msg_id("sct"),
created_at: None,
schema: CreditTransferSchema::default(),
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 schema(mut self, schema: CreditTransferSchema) -> 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: CreditTransferGroup) -> 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(CreditTransferGroup::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_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 check_instrument_and_timing(
g: &CreditTransferGroup,
schema: CreditTransferSchema,
) -> Result<(), ValidationError> {
let unsupported = |feature| ValidationError::UnsupportedBySchema {
feature,
schema: schema.message_id(),
};
if g.local_instrument == LocalInstrument::Inst && !schema.supports_local_instrument() {
return Err(unsupported("PmtTpInf/LclInstrm (SCT Inst)"));
}
let ExecutionMoment::At(moment) = g.execution else {
return Ok(());
};
if !schema.supports_execution_time() {
return Err(unsupported("ReqdExctnDt/DtTm (timed execution)"));
}
if g.local_instrument != LocalInstrument::Inst {
return Err(ValidationError::Requires {
feature: "ReqdExctnDt/DtTm (timed execution)",
requires: "PmtTpInf/LclInstrm = INST (SCT Inst)",
});
}
if moment.offset_minutes().is_none() {
return Err(ValidationError::Requires {
feature: "ReqdExctnDt/DtTm (timed execution)",
requires: "a UTC offset — see IsoDateTime::in_utc",
});
}
Ok(())
}
fn validate_group(
&self,
i: usize,
g: &CreditTransferGroup,
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(
"Dbtr/Nm",
&self.charset.apply("Dbtr/Nm", &g.debtor_name).at(at)?,
)
.at(at)?;
Self::check_instrument_and_timing(g, self.schema).at(at)?;
if let Some(a) = &g.debtor_address {
self.check_address_supported("Dbtr/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_debtor {
p.validate("PmtInf/UltmtDbtr", self.charset).at(at)?;
}
for (j, e) in g.entries.iter().enumerate() {
let at = Location::transaction(i, j);
if g.ultimate_debtor.is_some() && e.ultimate_debtor.is_some() {
return Err(BuildError {
location: at,
kind: ValidationError::ConflictingLevels { field: "UltmtDbtr" },
});
}
check_id("CdtTrfTxInf/PmtId/EndToEndId", &e.end_to_end_id).at(at)?;
check_amount("CdtTrfTxInf/Amt/InstdAmt", e.amount_ct).at(at)?;
check_name(
"Cdtr/Nm",
&self.charset.apply("Cdtr/Nm", &e.creditor_name).at(at)?,
)
.at(at)?;
if let Some(a) = &e.creditor_address {
self.check_address_supported("Cdtr/PstlAdr").at(at)?;
a.validate(self.charset).at(at)?;
}
if let Some(p) = &e.ultimate_debtor {
p.validate("CdtTrfTxInf/UltmtDbtr", self.charset).at(at)?;
}
if let Some(p) = &e.ultimate_creditor {
p.validate("CdtTrfTxInf/UltmtCdtr", self.charset).at(at)?;
}
if let Some(p) = &e.purpose {
p.validate("CdtTrfTxInf/Purp/Cd").at(at)?;
}
if let Some(r) = &e.remittance {
r.validate(remittance_field(r), self.charset).at(at)?;
}
*total = total.checked_add(e.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(850 + self.entry_count() * 420);
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(" <CstmrCdtTrfInitn>\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(" </CstmrCdtTrfInitn>\n</Document>")
}
fn write_group<W: std::fmt::Write>(
&self,
w: &mut W,
g: &CreditTransferGroup,
payment_info_id: &str,
) -> std::fmt::Result {
use crate::xml_util::write_escaped;
let bic_el = self.schema.bic_element();
let debtor_name = self.charset.render(&g.debtor_name);
w.write_str(" <PmtInf>\n <PmtInfId>")?;
write_escaped(w, payment_info_id)?;
w.write_str("</PmtInfId>\n <PmtMtd>TRF</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")?;
if g.local_instrument == LocalInstrument::Inst {
w.write_str(" <LclInstrm><Cd>INST</Cd></LclInstrm>\n")?;
}
if let Some(p) = &g.category_purpose {
writeln!(w, " <CtgyPurp><Cd>{}</Cd></CtgyPurp>", p.as_code())?;
}
w.write_str(" </PmtTpInf>\n")?;
match (self.schema.supports_execution_time(), g.execution) {
(true, ExecutionMoment::On(date)) => {
writeln!(w, " <ReqdExctnDt><Dt>{date}</Dt></ReqdExctnDt>")?;
}
(true, ExecutionMoment::At(moment)) => {
writeln!(w, " <ReqdExctnDt><DtTm>{moment}</DtTm></ReqdExctnDt>")?;
}
(false, moment) => {
writeln!(w, " <ReqdExctnDt>{}</ReqdExctnDt>", moment.date())?;
}
}
w.write_str(" <Dbtr><Nm>")?;
write_escaped(w, &debtor_name)?;
w.write_str("</Nm>")?;
if let Some(address) = &g.debtor_address {
address.write_xml(w, self.charset)?;
}
w.write_str("</Dbtr>\n")?;
writeln!(
w,
" <DbtrAcct><Id><IBAN>{}</IBAN></Id></DbtrAcct>",
g.debtor_iban.as_str()
)?;
w.write_str(" <DbtrAgt><FinInstnId>")?;
match &g.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")?;
if let Some(p) = &g.ultimate_debtor {
p.write_xml(w, "UltmtDbtr", " ", self.charset)?;
}
w.write_str(" <ChrgBr>SLEV</ChrgBr>\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: &CreditTransferEntry,
) -> std::fmt::Result {
use crate::xml_util::{write_escaped, write_eur};
let bic_el = self.schema.bic_element();
let name = self.charset.render(&e.creditor_name);
w.write_str(" <CdtTrfTxInf>\n <PmtId>\n <EndToEndId>")?;
write_escaped(w, &e.end_to_end_id)?;
w.write_str("</EndToEndId>\n </PmtId>\n <Amt><InstdAmt Ccy=\"EUR\">")?;
write_eur(w, e.amount_ct)?;
w.write_str("</InstdAmt></Amt>\n")?;
if let Some(p) = &e.ultimate_debtor {
p.write_xml(w, "UltmtDbtr", " ", self.charset)?;
}
if let Some(bic) = &e.creditor_bic {
writeln!(
w,
" <CdtrAgt><FinInstnId><{bic_el}>{}</{bic_el}></FinInstnId></CdtrAgt>",
bic.as_str()
)?;
}
w.write_str(" <Cdtr><Nm>")?;
write_escaped(w, &name)?;
w.write_str("</Nm>")?;
if let Some(address) = &e.creditor_address {
address.write_xml(w, self.charset)?;
}
w.write_str("</Cdtr>\n <CdtrAcct><Id><IBAN>")?;
w.write_str(e.creditor_iban.as_str())?;
w.write_str("</IBAN></Id></CdtrAcct>\n")?;
if let Some(p) = &e.ultimate_creditor {
p.write_xml(w, "UltmtCdtr", " ", 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(" </CdtTrfTxInf>\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 entry(amount_ct: i64) -> CreditTransferEntry {
CreditTransferEntry::new("Max Mustermann", nl_iban(), amount_ct, "E2E-001")
}
fn one_group(name: &str) -> Pain001Builder {
Pain001Builder::new(name).msg_id("CT-001").add_group(
CreditTransferGroup::new(name, &de_iban())
.execution_date(d("2026-07-20"))
.add_entry(entry(12_000)),
)
}
#[test]
fn basic_structure() {
let xml = one_group("Acme GmbH").build().unwrap();
assert!(xml.contains("urn:iso:std:iso:20022:tech:xsd:pain.001.001.09"));
assert!(xml.contains("<MsgId>CT-001</MsgId>"));
assert!(xml.contains("<PmtMtd>TRF</PmtMtd>"));
assert!(xml.contains("<InstdAmt Ccy=\"EUR\">120.00</InstdAmt>"));
assert!(xml.contains("<ChrgBr>SLEV</ChrgBr>"));
assert!(xml.contains("<ReqdExctnDt><Dt>2026-07-20</Dt></ReqdExctnDt>"));
}
#[test]
fn several_groups_carry_their_own_dates_and_totals() {
let xml = Pain001Builder::new("Acme GmbH")
.msg_id("CT-MULTI")
.add_group(
CreditTransferGroup::new("Acme GmbH", &de_iban())
.execution_date(d("2026-07-20"))
.add_entry(entry(10_000)),
)
.add_group(
CreditTransferGroup::new("Acme GmbH", &nl_iban())
.execution_date(d("2026-07-25"))
.add_entry(entry(5_000))
.add_entry(entry(2_500)),
)
.build()
.unwrap();
assert_eq!(xml.matches("<PmtInf>").count(), 2);
assert!(xml.contains("<Dt>2026-07-20</Dt>"));
assert!(xml.contains("<Dt>2026-07-25</Dt>"));
assert!(xml.contains("<NbOfTxs>3</NbOfTxs>"));
assert!(xml.contains("<CtrlSum>175.00</CtrlSum>"));
assert!(xml.contains("<NbOfTxs>1</NbOfTxs>"));
assert!(xml.contains("<CtrlSum>100.00</CtrlSum>"));
assert!(xml.contains("<NbOfTxs>2</NbOfTxs>"));
assert!(xml.contains("<CtrlSum>75.00</CtrlSum>"));
}
#[test]
fn payment_info_ids_are_unique_and_within_max35text() {
let msg_id = "M".repeat(35);
let b = Pain001Builder::new("Acme").msg_id(&msg_id);
let b = (0..3).fold(b, |b, _| {
b.add_group(
CreditTransferGroup::new("Acme", &de_iban())
.execution_date(d("2026-07-20"))
.add_entry(entry(100)),
)
});
let xml = b.build().unwrap();
let ids: Vec<&str> = xml
.split("<PmtInfId>")
.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");
}
let unique: std::collections::BTreeSet<_> = ids.iter().collect();
assert_eq!(unique.len(), 3, "PmtInfId must be unique per group");
}
#[test]
fn two_groups_may_not_share_a_payment_info_id() {
let g = |id: &str| {
CreditTransferGroup::new("Acme", &de_iban())
.payment_info_id(id)
.execution_date(d("2026-07-20"))
.add_entry(entry(100))
};
let err = Pain001Builder::new("Acme")
.msg_id("CT-DUP")
.add_group(g("PMT-1"))
.add_group(g("PMT-1"))
.build()
.unwrap_err();
assert_eq!(err.location, Location::group(1));
assert_eq!(
err.kind,
ValidationError::Duplicate {
field: "PmtInf/PmtInfId",
value: "PMT-1".to_owned(),
}
);
assert!(
Pain001Builder::new("Acme")
.msg_id("CT-OK")
.add_group(g("PMT-1"))
.add_group(g("PMT-2"))
.build()
.is_ok()
);
}
#[test]
fn the_generated_message_id_is_unique_per_message() {
let ids: std::collections::BTreeSet<String> = (0..64)
.map(|_| Pain001Builder::new("Acme").msg_id.clone())
.collect();
assert_eq!(ids.len(), 64, "generated MsgIds collided");
for id in &ids {
assert!(
crate::validate::check_id("GrpHdr/MsgId", id).is_ok(),
"{id}"
);
}
}
#[test]
fn single_group_reuses_the_msg_id_verbatim() {
let xml = one_group("Acme").build().unwrap();
assert!(xml.contains("<PmtInfId>CT-001</PmtInfId>"));
}
#[test]
fn legacy_dk_schema_emits_a_bare_date_and_bic() {
let xml = Pain001Builder::new("Test")
.schema(CreditTransferSchema::DkV2_7)
.msg_id("CT-DK")
.add_group(
CreditTransferGroup::new("Test", &de_iban())
.execution_date(d("2026-07-20"))
.debtor_bic("COBADEFF".parse().unwrap())
.add_entry(entry(5_000).with_bic("ABNANL2A".parse().unwrap())),
)
.build()
.unwrap();
assert!(xml.contains("pain.001.003.03"));
assert!(xml.contains("<ReqdExctnDt>2026-07-20</ReqdExctnDt>"));
assert!(xml.contains("<BIC>COBADEFF</BIC>"));
assert!(!xml.contains("BICFI"));
}
#[test]
fn iban_only_agents_and_omitted_creditor_agent() {
let xml = one_group("Test").build().unwrap();
assert!(!xml.contains("<CdtrAgt>"), "EPC omits CdtrAgt with no BIC");
assert!(xml.contains("<DbtrAgt><FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr>"));
assert!(!xml.contains("NOTPROVIDED</BIC"));
}
#[test]
fn sct_instant_marks_the_group() {
let xml = Pain001Builder::new("Acme")
.msg_id("CT-INST")
.add_group(
CreditTransferGroup::new("Acme", &de_iban())
.local_instrument(LocalInstrument::Inst)
.execution_date(d("2026-07-20"))
.add_entry(entry(5_000)),
)
.build()
.unwrap();
assert!(xml.contains("pain.001.001.09"));
assert!(xml.contains("<LclInstrm><Cd>INST</Cd></LclInstrm>"));
}
#[test]
fn postal_addresses_sit_inside_the_party_after_the_name() {
let xml = Pain001Builder::new("Acme GmbH")
.msg_id("CT-ADR")
.add_group(
CreditTransferGroup::new("Acme GmbH", &de_iban())
.execution_date(d("2026-07-20"))
.debtor_address(
crate::PostalAddress::new("Berlin", "DE")
.unwrap()
.street("Unter den Linden")
.building_number("77")
.post_code("10117"),
)
.add_entry(entry(12_000).with_creditor_address(
crate::PostalAddress::new("Amsterdam", "NL").unwrap(),
)),
)
.build()
.unwrap();
assert!(xml.contains(
"<Dbtr><Nm>Acme GmbH</Nm><PstlAdr><StrtNm>Unter den Linden</StrtNm>\
<BldgNb>77</BldgNb><PstCd>10117</PstCd><TwnNm>Berlin</TwnNm>\
<Ctry>DE</Ctry></PstlAdr></Dbtr>"
));
assert!(xml.contains(
"<Cdtr><Nm>Max Mustermann</Nm><PstlAdr><TwnNm>Amsterdam</TwnNm>\
<Ctry>NL</Ctry></PstlAdr></Cdtr>"
));
}
#[test]
fn an_address_on_a_schema_without_one_is_rejected() {
let err = Pain001Builder::new("Acme")
.schema(CreditTransferSchema::DkV2_7)
.msg_id("CT-DK-ADR")
.add_group(
CreditTransferGroup::new("Acme", &de_iban())
.execution_date(d("2026-07-20"))
.debtor_address(crate::PostalAddress::new("Berlin", "DE").unwrap())
.add_entry(entry(100)),
)
.build()
.unwrap_err();
assert_eq!(
err.kind,
ValidationError::UnsupportedBySchema {
feature: "Dbtr/PstlAdr",
schema: "pain.001.003.03",
}
);
assert_eq!(err.location, Location::group(0));
}
#[test]
fn address_violations_name_the_element_and_the_transaction() {
let err = Pain001Builder::new("Acme")
.msg_id("CT-ADR-BAD")
.add_group(
CreditTransferGroup::new("Acme", &de_iban())
.execution_date(d("2026-07-20"))
.add_entry(entry(100))
.add_entry(
entry(100).with_creditor_address(
crate::PostalAddress::new("Berlin", "DE")
.unwrap()
.post_code("1".repeat(17)),
),
),
)
.build()
.unwrap_err();
assert_eq!(err.location, Location::transaction(0, 1));
assert!(matches!(
err.kind,
ValidationError::TooLong {
field: "PstlAdr/PstCd",
max: 16,
..
}
));
}
#[test]
fn batch_booking_and_category_purpose_are_group_level() {
let xml = Pain001Builder::new("Acme")
.msg_id("CT-OPT")
.add_group(
CreditTransferGroup::new("Acme", &de_iban())
.execution_date(d("2026-07-20"))
.batch_booking(true)
.category_purpose(CategoryPurpose::Sala)
.add_entry(entry(100)),
)
.build()
.unwrap();
assert!(xml.contains("<BtchBookg>true</BtchBookg>"));
assert!(xml.contains("<CtgyPurp><Cd>SALA</Cd></CtgyPurp>"));
}
#[test]
fn batch_booking_is_omitted_unless_set() {
assert!(!one_group("Acme").build().unwrap().contains("BtchBookg"));
}
#[test]
fn ultimate_debtor_cannot_be_set_at_both_levels() {
let b = Pain001Builder::new("Acme").msg_id("CT-ULT").add_group(
CreditTransferGroup::new("Acme", &de_iban())
.execution_date(d("2026-07-20"))
.ultimate_debtor(Party::new("Gruppe"))
.add_entry(entry(100).with_ultimate_debtor(Party::new("Transaktion"))),
);
assert_eq!(
b.build(),
Err(BuildError::transaction(
0,
0,
ValidationError::ConflictingLevels { field: "UltmtDbtr" }
))
);
}
#[test]
fn empty_message_and_empty_group_are_both_rejected() {
assert_eq!(
Pain001Builder::new("Acme").msg_id("E").build(),
Err(BuildError::message(ValidationError::EmptyBatch))
);
assert_eq!(
Pain001Builder::new("Acme")
.msg_id("E")
.add_group(CreditTransferGroup::new("Acme", &de_iban()))
.build(),
Err(BuildError::group(0, ValidationError::EmptyBatch))
);
}
#[test]
fn validation_rejects_bad_fields() {
let g = || CreditTransferGroup::new("Acme", &de_iban()).execution_date(d("2026-07-20"));
let b = || Pain001Builder::new("Acme").msg_id("OK");
let kind = |r: Result<String, BuildError>| r.unwrap_err().kind;
assert!(matches!(
kind(b().add_group(g().add_entry(entry(0))).build()),
ValidationError::AmountOutOfRange { .. }
));
assert!(matches!(
kind(b().add_group(g().add_entry(entry(100_000_000_000))).build()),
ValidationError::AmountOutOfRange { .. }
));
assert!(matches!(
kind(
b().msg_id("X".repeat(36))
.add_group(g().add_entry(entry(100)))
.build()
),
ValidationError::TooLong { .. }
));
}
#[test]
fn errors_name_the_group_and_transaction_that_failed() {
let g = || CreditTransferGroup::new("Acme", &de_iban()).execution_date(d("2026-07-20"));
let err = Pain001Builder::new("Acme")
.msg_id("CT-LOC")
.add_group(g().add_entry(entry(100)))
.add_group(g().add_entry(entry(100)).add_entry(entry(0)))
.build()
.unwrap_err();
assert_eq!(err.location, Location::transaction(1, 1));
assert!(err.to_string().starts_with("PmtInf[1]/Tx[1]: "));
}
#[test]
fn schema_versions_round_trip_through_their_identifiers() {
for schema in CreditTransferSchema::ALL {
assert_eq!(
schema.message_id().parse::<CreditTransferSchema>(),
Ok(*schema)
);
assert_eq!(
schema.namespace().parse::<CreditTransferSchema>(),
Ok(*schema)
);
assert!(schema.namespace().ends_with(schema.message_id()));
}
assert!("pain.001.001.99".parse::<CreditTransferSchema>().is_err());
}
#[test]
fn the_epc_legacy_schema_emits_a_bare_date_and_the_pre_2019_bic_element() {
let xml = Pain001Builder::new("Acme")
.schema(CreditTransferSchema::IsoV3)
.msg_id("CT-V3")
.add_group(
CreditTransferGroup::new("Acme", &de_iban())
.execution_date(d("2026-07-20"))
.debtor_bic("COBADEFF".parse().unwrap())
.add_entry(entry(5_000).with_bic("ABNANL2A".parse().unwrap())),
)
.build()
.unwrap();
assert!(xml.contains("pain.001.001.03"));
assert!(xml.contains("<ReqdExctnDt>2026-07-20</ReqdExctnDt>"));
assert!(xml.contains("<BIC>COBADEFF</BIC>"));
assert!(!xml.contains("BICFI"));
}
#[test]
fn sct_instant_on_a_schema_without_lclinstrm_is_rejected() {
let err = Pain001Builder::new("Acme")
.schema(CreditTransferSchema::DkV2_7)
.msg_id("CT-DK-INST")
.add_group(
CreditTransferGroup::new("Acme", &de_iban())
.local_instrument(LocalInstrument::Inst)
.execution_date(d("2026-07-20"))
.add_entry(entry(5_000)),
)
.build()
.unwrap_err();
assert_eq!(
err.kind,
ValidationError::UnsupportedBySchema {
feature: "PmtTpInf/LclInstrm (SCT Inst)",
schema: "pain.001.003.03",
}
);
assert_eq!(err.location, Location::group(0));
for schema in [CreditTransferSchema::IsoV9, CreditTransferSchema::IsoV3] {
let xml = Pain001Builder::new("Acme")
.schema(schema)
.msg_id("CT-INST")
.add_group(
CreditTransferGroup::new("Acme", &de_iban())
.local_instrument(LocalInstrument::Inst)
.execution_date(d("2026-07-20"))
.add_entry(entry(5_000)),
)
.build()
.unwrap();
assert!(
xml.contains("<LclInstrm><Cd>INST</Cd></LclInstrm>"),
"{schema}"
);
}
}
#[test]
fn totals_use_integer_arithmetic() {
let b = Pain001Builder::new("Acme").msg_id("CT").add_group(
CreditTransferGroup::new("Acme", &de_iban())
.execution_date(d("2026-07-20"))
.add_entry(entry(10))
.add_entry(entry(20)),
);
assert_eq!(b.total_ct(), 30);
assert!(b.build().unwrap().contains("<CtrlSum>0.30</CtrlSum>"));
}
#[test]
fn creation_timestamp_can_be_pinned_for_reproducible_output() {
let build = || {
one_group("Acme")
.created_at("2026-07-19T12:00:00".parse().unwrap())
.build()
.unwrap()
};
assert_eq!(build(), build(), "output must be byte-identical");
assert!(build().contains("<CreDtTm>2026-07-19T12:00:00</CreDtTm>"));
}
#[test]
fn non_sepa_characters_are_transliterated() {
let xml = Pain001Builder::new("Müller & Söhne GmbH")
.msg_id("CT-UML")
.add_group(
CreditTransferGroup::new("Müller & Söhne GmbH", &de_iban())
.execution_date(d("2026-07-20"))
.add_entry(
CreditTransferEntry::new("Ökonomie AG", nl_iban(), 100, "E2E-1")
.with_description("Zahlung für Groß-Auftrag"),
),
)
.build()
.unwrap();
assert!(xml.contains("Mueller + Soehne GmbH"));
assert!(xml.contains("Oekonomie AG"));
assert!(xml.contains("Zahlung fuer Gross-Auftrag"));
}
#[test]
fn strict_charset_policy_rejects_umlauts() {
assert!(matches!(
one_group("Müller GmbH")
.charset(CharsetPolicy::Strict)
.build()
.unwrap_err()
.kind,
ValidationError::InvalidCharacter { ch: 'ü', .. }
));
}
#[test]
fn streaming_matches_the_in_memory_build() {
let direct = one_group("Acme")
.created_at("2026-07-19T12:00:00".parse().unwrap())
.build()
.unwrap();
let mut buf: Vec<u8> = Vec::new();
one_group("Acme")
.created_at("2026-07-19T12:00:00".parse().unwrap())
.write_to(&mut buf)
.unwrap();
assert_eq!(direct, String::from_utf8(buf).unwrap());
let mut empty: Vec<u8> = Vec::new();
assert!(Pain001Builder::new("Acme").write_to(&mut empty).is_err());
assert!(empty.is_empty());
}
}