use crate::party::Party;
use crate::purpose::{CategoryPurpose, Purpose};
use crate::reference::RemittanceInfo;
use crate::validate::{
CharsetPolicy, MAX_ID_LEN, ValidationError, WriteError, check_amount, check_date, check_id,
check_name, check_remittance, truncate_chars,
};
use crate::{Bic, Iban, ct_to_eur_str};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CreditTransferSchema {
#[default]
IsoV9,
DkV2_7,
}
impl CreditTransferSchema {
#[must_use]
pub const fn namespace(self) -> &'static str {
match self {
Self::DkV2_7 => "urn:iso:std:iso:20022:tech:xsd:pain.001.003.03",
Self::IsoV9 => "urn:iso:std:iso:20022:tech:xsd:pain.001.001.09",
}
}
#[must_use]
const fn bic_element(self) -> &'static str {
match self {
Self::DkV2_7 => "BIC",
Self::IsoV9 => "BICFI",
}
}
#[must_use]
const fn wraps_execution_date(self) -> bool {
matches!(self, Self::IsoV9)
}
}
#[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)]
#[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 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,
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_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>,
execution_date: String,
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,
execution_date: crate::pain008::default_collection_date(),
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: impl Into<String>) -> Self {
self.execution_date = date.into();
self
}
#[must_use]
pub fn debtor_bic(mut self, bic: Bic) -> Self {
self.debtor_bic = Some(bic);
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<String>,
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: format!("sct-{}", crate::pain008::epoch_secs()),
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: impl Into<String>) -> Self {
self.created_at = Some(timestamp.into());
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))
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.groups.is_empty() || self.entry_count() == 0 {
return Err(ValidationError::EmptyBatch);
}
check_id("GrpHdr/MsgId", &self.msg_id)?;
check_name(
"InitgPty/Nm",
&self.charset.apply("InitgPty/Nm", &self.initiating_party)?,
)?;
let mut total: i64 = 0;
for (i, g) in self.groups.iter().enumerate() {
if g.entries.is_empty() {
return Err(ValidationError::EmptyBatch);
}
check_id("PmtInf/PmtInfId", &self.payment_info_id(i))?;
check_date("PmtInf/ReqdExctnDt", &g.execution_date)?;
check_name("Dbtr/Nm", &self.charset.apply("Dbtr/Nm", &g.debtor_name)?)?;
if let Some(p) = &g.category_purpose {
p.validate("PmtTpInf/CtgyPurp/Cd")?;
}
if let Some(p) = &g.ultimate_debtor {
p.validate("PmtInf/UltmtDbtr", self.charset)?;
}
for e in &g.entries {
if g.ultimate_debtor.is_some() && e.ultimate_debtor.is_some() {
return Err(ValidationError::ConflictingLevels { field: "UltmtDbtr" });
}
check_id("CdtTrfTxInf/PmtId/EndToEndId", &e.end_to_end_id)?;
check_amount("CdtTrfTxInf/Amt/InstdAmt", e.amount_ct)?;
check_name("Cdtr/Nm", &self.charset.apply("Cdtr/Nm", &e.creditor_name)?)?;
if let Some(p) = &e.ultimate_debtor {
p.validate("CdtTrfTxInf/UltmtDbtr", self.charset)?;
}
if let Some(p) = &e.ultimate_creditor {
p.validate("CdtTrfTxInf/UltmtCdtr", self.charset)?;
}
if let Some(p) = &e.purpose {
p.validate("CdtTrfTxInf/Purp/Cd")?;
}
if let Some(r) = &e.remittance {
if let RemittanceInfo::Unstructured(text) = r {
check_remittance(
"RmtInf/Ustrd",
&self.charset.apply("RmtInf/Ustrd", text)?,
)?;
} else {
r.validate("RmtInf/Strd")?;
}
}
total = total
.checked_add(e.amount_ct)
.ok_or(ValidationError::ControlSumOverflow)?;
}
}
Ok(())
}
pub fn build(&self) -> Result<String, ValidationError> {
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
.clone()
.unwrap_or_else(crate::pain008::iso8601_now);
let namespace = self.schema.namespace();
let initiating = self
.charset
.apply("InitgPty/Nm", &self.initiating_party)
.unwrap_or(std::borrow::Cow::Borrowed(&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
.apply("Dbtr/Nm", &g.debtor_name)
.unwrap_or(std::borrow::Cow::Borrowed(&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")?;
if self.schema.wraps_execution_date() {
writeln!(
w,
" <ReqdExctnDt><Dt>{}</Dt></ReqdExctnDt>",
g.execution_date
)?;
} else {
writeln!(w, " <ReqdExctnDt>{}</ReqdExctnDt>", g.execution_date)?;
}
w.write_str(" <Dbtr><Nm>")?;
write_escaped(w, &debtor_name)?;
w.write_str("</Nm></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
.apply("Cdtr/Nm", &e.creditor_name)
.unwrap_or(std::borrow::Cow::Borrowed(&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></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, ValidationError};
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("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("2026-07-20")
.add_entry(entry(10_000)),
)
.add_group(
CreditTransferGroup::new("Acme GmbH", &nl_iban())
.execution_date("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("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 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("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_selects_the_iso_schema_and_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("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 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("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("2026-07-20")
.ultimate_debtor(Party::new("Gruppe"))
.add_entry(entry(100).with_ultimate_debtor(Party::new("Transaktion"))),
);
assert_eq!(
b.build(),
Err(ValidationError::ConflictingLevels { field: "UltmtDbtr" })
);
}
#[test]
fn empty_message_and_empty_group_are_both_rejected() {
assert_eq!(
Pain001Builder::new("Acme").msg_id("E").build(),
Err(ValidationError::EmptyBatch)
);
assert_eq!(
Pain001Builder::new("Acme")
.msg_id("E")
.add_group(CreditTransferGroup::new("Acme", &de_iban()))
.build(),
Err(ValidationError::EmptyBatch)
);
}
#[test]
fn validation_rejects_bad_fields() {
let g = || CreditTransferGroup::new("Acme", &de_iban()).execution_date("2026-07-20");
let b = || Pain001Builder::new("Acme").msg_id("OK");
assert!(matches!(
b().add_group(g().add_entry(entry(0))).build(),
Err(ValidationError::AmountOutOfRange { .. })
));
assert!(matches!(
b().add_group(g().add_entry(entry(100_000_000_000))).build(),
Err(ValidationError::AmountOutOfRange { .. })
));
assert!(matches!(
b().msg_id("X".repeat(36))
.add_group(g().add_entry(entry(100)))
.build(),
Err(ValidationError::TooLong { .. })
));
assert!(matches!(
b().add_group(
CreditTransferGroup::new("Acme", &de_iban())
.execution_date("2026-02-30")
.add_entry(entry(100))
)
.build(),
Err(ValidationError::InvalidDate { .. })
));
}
#[test]
fn totals_use_integer_arithmetic() {
let b = Pain001Builder::new("Acme").msg_id("CT").add_group(
CreditTransferGroup::new("Acme", &de_iban())
.execution_date("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")
.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("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(),
Err(ValidationError::InvalidCharacter { ch: 'ü', .. })
));
}
#[test]
fn streaming_matches_the_in_memory_build() {
let direct = one_group("Acme")
.created_at("2026-07-19T12:00:00")
.build()
.unwrap();
let mut buf: Vec<u8> = Vec::new();
one_group("Acme")
.created_at("2026-07-19T12:00:00")
.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());
}
}