use crate::{Bic, Iban, ct_to_eur_str};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CreditTransferSchema {
#[default]
DkV2_7,
IsoV9,
}
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",
}
}
}
#[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 description: Option<String>,
}
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,
description: 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.description = Some(desc.into());
self
}
}
#[derive(Debug)]
pub struct Pain001Builder {
debtor_name: String,
debtor_iban: Iban,
debtor_bic: Option<Bic>,
msg_id: String,
execution_date: String,
schema: CreditTransferSchema,
local_instrument: LocalInstrument,
entries: Vec<CreditTransferEntry>,
}
impl Pain001Builder {
pub fn new(debtor_name: impl Into<String>, debtor_iban: &Iban) -> Self {
Self {
debtor_name: debtor_name.into(),
debtor_iban: debtor_iban.clone(),
debtor_bic: None,
msg_id: format!("sct-{}", crate::pain008::epoch_secs()),
execution_date: crate::pain008::default_collection_date(),
schema: CreditTransferSchema::DkV2_7,
local_instrument: LocalInstrument::None,
entries: 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 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 schema(mut self, schema: CreditTransferSchema) -> Self {
self.schema = schema;
self
}
#[must_use]
pub fn local_instrument(mut self, li: LocalInstrument) -> Self {
self.local_instrument = li;
if li == LocalInstrument::Inst {
self.schema = CreditTransferSchema::IsoV9;
}
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().map(|e| e.amount_ct).sum()
}
#[must_use]
pub fn build_xml(&self) -> String {
let mut buf = String::with_capacity(850 + self.entries.len() * 420);
self.write_xml_to(&mut buf)
.expect("in-memory String write is infallible");
buf
}
pub fn write_xml_to<W: std::fmt::Write>(&self, w: &mut W) -> std::fmt::Result {
use crate::xml_util::write_escaped;
let now = crate::pain008::iso8601_now();
let total_eur = ct_to_eur_str(self.total_ct());
let nb = self.entries.len();
let debtor_bic = self.debtor_bic.as_ref().map_or("NOTPROVIDED", Bic::as_str);
let namespace = self.schema.namespace();
let debtor_iban = self.debtor_iban.as_str();
let execution_date = &self.execution_date;
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>{nb}</NbOfTxs>\n <CtrlSum>{total_eur}</CtrlSum>\n"
)?;
w.write_str(" <InitgPty><Nm>")?;
write_escaped(w, &self.debtor_name)?;
w.write_str("</Nm></InitgPty>\n </GrpHdr>\n <PmtInf>\n <PmtInfId>")?;
write_escaped(w, &self.msg_id)?;
w.write_str("-1</PmtInfId>\n")?;
w.write_str(" <PmtMtd>TRF</PmtMtd>\n")?;
write!(
w,
" <NbOfTxs>{nb}</NbOfTxs>\n <CtrlSum>{total_eur}</CtrlSum>\n"
)?;
w.write_str(" <PmtTpInf>\n <SvcLvl><Cd>SEPA</Cd></SvcLvl>\n")?;
if self.local_instrument == LocalInstrument::Inst {
w.write_str(" <LclInstrm><Cd>INST</Cd></LclInstrm>\n")?;
}
w.write_str(" </PmtTpInf>\n")?;
writeln!(w, " <ReqdExctnDt>{execution_date}</ReqdExctnDt>")?;
w.write_str(" <Dbtr><Nm>")?;
write_escaped(w, &self.debtor_name)?;
w.write_str("</Nm></Dbtr>\n")?;
writeln!(
w,
" <DbtrAcct><Id><IBAN>{debtor_iban}</IBAN></Id></DbtrAcct>"
)?;
writeln!(
w,
" <DbtrAgt><FinInstnId><BIC>{debtor_bic}</BIC></FinInstnId></DbtrAgt>"
)?;
w.write_str(" <ChrgBr>SLEV</ChrgBr>\n")?;
for entry in &self.entries {
Self::write_transaction(w, entry)?;
}
w.write_str(" </PmtInf>\n </CstmrCdtTrfInitn>\n</Document>")
}
pub fn write_xml_to_io<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
let mut bridge = crate::xml_util::IoWriterBridge {
inner: w,
error: None,
};
if self.write_xml_to(&mut bridge).is_err() {
if let Some(e) = bridge.error {
return Err(e);
}
}
Ok(())
}
fn write_transaction<W: std::fmt::Write>(
w: &mut W,
e: &CreditTransferEntry,
) -> std::fmt::Result {
use crate::xml_util::{write_escaped, write_eur};
let creditor_bic = e.creditor_bic.as_ref().map_or("NOTPROVIDED", Bic::as_str);
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 <CdtrAgt><FinInstnId><BIC>")?;
w.write_str(creditor_bic)?;
w.write_str("</BIC></FinInstnId></CdtrAgt>\n <Cdtr><Nm>")?;
write_escaped(w, &e.creditor_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(desc) = &e.description {
let truncated = if desc.len() > 140 { &desc[..140] } else { desc };
w.write_str(" <RmtInf><Ustrd>")?;
write_escaped(w, truncated)?;
w.write_str("</Ustrd></RmtInf>\n")?;
}
w.write_str(" </CdtTrfTxInf>\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::iban::validate_iban;
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")
}
#[test]
fn basic_xml_structure() {
let xml = Pain001Builder::new("Acme GmbH", &de_iban())
.msg_id("CT-001")
.execution_date("2026-07-20")
.add_entry(
CreditTransferEntry::new("Max Mustermann", nl_iban(), 12_000, "REFUND-2025")
.with_bic("ABNANL2A".parse::<Bic>().unwrap())
.with_description("Erstattung 2025"),
)
.build_xml();
assert!(xml.contains("urn:iso:std:iso:20022:tech:xsd:pain.001.003.03"));
assert!(xml.contains("<MsgId>CT-001</MsgId>"));
assert!(xml.contains("<NbOfTxs>1</NbOfTxs>"));
assert!(xml.contains("<InstdAmt Ccy=\"EUR\">120.00</InstdAmt>"));
assert!(xml.contains("<Nm>Max Mustermann</Nm>"));
assert!(xml.contains("<IBAN>NL91ABNA0417164300</IBAN>"));
assert!(xml.contains("<ReqdExctnDt>2026-07-20</ReqdExctnDt>"));
assert!(xml.contains("Erstattung 2025"));
assert!(xml.contains("<PmtMtd>TRF</PmtMtd>"));
assert!(xml.contains("<ChrgBr>SLEV</ChrgBr>"));
assert!(!xml.contains("<LclInstrm>"));
}
#[test]
fn sct_inst_uses_pain001_001_09() {
use crate::pain001::LocalInstrument;
let xml = Pain001Builder::new("Acme GmbH", &de_iban())
.msg_id("CT-INST-001")
.local_instrument(LocalInstrument::Inst)
.add_entry(entry(5_000))
.build_xml();
assert!(xml.contains("urn:iso:std:iso:20022:tech:xsd:pain.001.001.09"));
assert!(!xml.contains("pain.001.003.03"));
assert!(xml.contains("<LclInstrm><Cd>INST</Cd></LclInstrm>"));
assert!(xml.contains("<ChrgBr>SLEV</ChrgBr>"));
}
#[test]
fn explicit_iso_v9_schema() {
use crate::pain001::CreditTransferSchema;
let xml = Pain001Builder::new("Test", &de_iban())
.schema(CreditTransferSchema::IsoV9)
.build_xml();
assert!(xml.contains("pain.001.001.09"));
}
#[test]
fn ctrl_sum_correct() {
let xml = Pain001Builder::new("Test", &de_iban())
.msg_id("CT-002")
.execution_date("2026-07-20")
.add_entry(entry(5_000))
.add_entry(entry(7_500))
.build_xml();
assert!(xml.contains("<CtrlSum>125.00</CtrlSum>"));
assert!(xml.contains("<NbOfTxs>2</NbOfTxs>"));
}
#[test]
fn no_f64_rounding() {
let builder = Pain001Builder::new("Test", &de_iban())
.add_entry(entry(10))
.add_entry(entry(20));
assert_eq!(builder.total_ct(), 30);
assert!(builder.build_xml().contains("<CtrlSum>0.30</CtrlSum>"));
}
#[test]
fn xml_escaping_in_names() {
let xml = Pain001Builder::new("Test & Co. <GmbH>", &de_iban())
.msg_id("CT-ESC")
.build_xml();
assert!(xml.contains("Test & Co. <GmbH>"));
}
#[test]
fn empty_batch() {
let xml = Pain001Builder::new("Test", &de_iban())
.msg_id("EMPTY")
.build_xml();
assert!(xml.contains("<NbOfTxs>0</NbOfTxs>"));
assert!(xml.contains("<CtrlSum>0.00</CtrlSum>"));
}
}