use std::str::FromStr;
use crate::creditor_id::CreditorId;
use crate::{Bic, Iban, ct_to_eur_str};
#[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)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DirectDebitEntry {
pub mandate_ref: String,
pub mandate_signed_at: String,
pub debtor_name: String,
pub debtor_iban: Iban,
pub debtor_bic: Option<Bic>,
pub amount_ct: i64,
pub end_to_end_id: String,
pub description: Option<String>,
}
impl DirectDebitEntry {
pub fn new(
mandate_ref: impl Into<String>,
mandate_signed_at: impl Into<String>,
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: mandate_signed_at.into(),
debtor_name: debtor_name.into(),
debtor_iban,
amount_ct,
end_to_end_id: end_to_end_id.into(),
debtor_bic: None,
description: None,
}
}
#[must_use]
pub fn with_bic(mut self, bic: Bic) -> Self {
self.debtor_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 Pain008Builder {
creditor_name: String,
creditor_iban: Iban,
creditor_bic: Option<Bic>,
creditor_id: Option<CreditorId>,
msg_id: String,
collection_date: String,
sequence_type: SequenceType,
scheme: DirectDebitScheme,
entries: Vec<DirectDebitEntry>,
}
impl Pain008Builder {
pub fn new(creditor_name: impl Into<String>, creditor_iban: &Iban) -> Self {
Self {
creditor_name: creditor_name.into(),
creditor_iban: creditor_iban.clone(),
creditor_bic: None,
creditor_id: None,
msg_id: format!("sepa-{}", epoch_secs()),
collection_date: default_collection_date(),
sequence_type: SequenceType::Rcur,
scheme: DirectDebitScheme::Core,
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 scheme(mut self, scheme: DirectDebitScheme) -> Self {
self.scheme = scheme;
self
}
#[must_use]
pub fn sequence_type(mut self, st: SequenceType) -> Self {
self.sequence_type = st;
self
}
#[must_use]
pub fn collection_date(mut self, date: impl Into<String>) -> Self {
self.collection_date = date.into();
self
}
#[must_use]
pub fn creditor_bic(mut self, bic: Bic) -> Self {
self.creditor_bic = Some(bic);
self
}
#[must_use]
pub fn creditor_id(mut self, id: CreditorId) -> Self {
self.creditor_id = Some(id);
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().map(|e| e.amount_ct).sum()
}
#[must_use]
pub fn build_xml(&self) -> String {
let mut buf = String::with_capacity(900 + self.entries.len() * 480);
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 = iso8601_now();
let total_eur = ct_to_eur_str(self.total_ct());
let nb = self.entries.len();
let creditor_bic = self
.creditor_bic
.as_ref()
.map_or("NOTPROVIDED", Bic::as_str);
let scheme_code = self.scheme.as_code();
let seq_tp = self.sequence_type.as_code();
let creditor_iban = self.creditor_iban.as_str();
let collection_date = &self.collection_date;
w.write_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")?;
writeln!(
w,
"<Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:pain.008.003.02\">"
)?;
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>{nb}</NbOfTxs>\n <CtrlSum>{total_eur}</CtrlSum>\n"
)?;
w.write_str(" <InitgPty><Nm>")?;
write_escaped(w, &self.creditor_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>DD</PmtMtd>\n")?;
write!(
w,
" <NbOfTxs>{nb}</NbOfTxs>\n <CtrlSum>{total_eur}</CtrlSum>\n"
)?;
w.write_str(" <PmtTpInf>\n <SvcLvl><Cd>SEPA</Cd></SvcLvl>\n")?;
writeln!(w, " <LclInstrm><Cd>{scheme_code}</Cd></LclInstrm>")?;
write!(w, " <SeqTp>{seq_tp}</SeqTp>\n </PmtTpInf>\n")?;
writeln!(w, " <ReqdColltnDt>{collection_date}</ReqdColltnDt>")?;
w.write_str(" <Cdtr><Nm>")?;
write_escaped(w, &self.creditor_name)?;
w.write_str("</Nm></Cdtr>\n")?;
writeln!(
w,
" <CdtrAcct><Id><IBAN>{creditor_iban}</IBAN></Id></CdtrAcct>"
)?;
writeln!(
w,
" <CdtrAgt><FinInstnId><BIC>{creditor_bic}</BIC></FinInstnId></CdtrAgt>"
)?;
w.write_str(" <ChrgBr>SLEV</ChrgBr>\n")?;
if let Some(ci) = &self.creditor_id {
w.write_str(" <CdtrSchmeId><Id><PrvtId><Othr><Id>")?;
w.write_str(ci.as_str())?;
w.write_str(
"</Id><SchmeNm><Prtry>SEPA</Prtry></SchmeNm></Othr></PrvtId></Id></CdtrSchmeId>\n",
)?;
}
for entry in &self.entries {
Self::write_transaction(w, entry)?;
}
w.write_str(" </PmtInf>\n </CstmrDrctDbtInitn>\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: &DirectDebitEntry) -> std::fmt::Result {
use crate::xml_util::{write_escaped, write_eur};
let debtor_bic = e.debtor_bic.as_ref().map_or("NOTPROVIDED", Bic::as_str);
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>")?;
w.write_str(&e.mandate_signed_at)?;
w.write_str("</DtOfSgntr>\n </MndtRltdInf>\n </DrctDbtTx>\n")?;
w.write_str(" <DbtrAgt><FinInstnId><BIC>")?;
w.write_str(debtor_bic)?;
w.write_str("</BIC></FinInstnId></DbtrAgt>\n <Dbtr><Nm>")?;
write_escaped(w, &e.debtor_name)?;
w.write_str("</Nm></Dbtr>\n <DbtrAcct><Id><IBAN>")?;
w.write_str(e.debtor_iban.as_str())?;
w.write_str("</IBAN></Id></DbtrAcct>\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(" </DrctDbtTxInf>\n")
}
}
pub(crate) fn iso8601_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (y, mo, d) = days_to_ymd(secs / 86400);
let ss = secs % 60;
let mm = (secs / 60) % 60;
let hh = (secs / 3600) % 24;
format!("{y:04}-{mo:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}")
}
pub(crate) fn epoch_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub(crate) fn default_collection_date() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (y, m, d) = days_to_ymd(secs / 86400 + 5);
format!("{y:04}-{m:02}-{d:02}")
}
#[allow(
clippy::cast_possible_wrap, // days fits in i64 for any calendar date
clippy::cast_possible_truncation, // doe is 0..146096, fits u32
clippy::cast_sign_loss, // doe is always non-negative
)]
fn days_to_ymd(days: u64) -> (u32, u32, u32) {
let z = days as i64 + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u32; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = i64::from(yoe) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(u32::try_from(y).unwrap_or(0), m, d)
}
#[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(mandate: &str, amount_ct: i64) -> DirectDebitEntry {
DirectDebitEntry::new(
mandate,
"2024-01-01",
"Max Mustermann",
nl_iban(),
amount_ct,
mandate,
)
}
#[test]
fn basic_xml_structure() {
let xml = Pain008Builder::new("Test GmbH", &de_iban())
.msg_id("TEST-001")
.sequence_type(SequenceType::Rcur)
.collection_date("2026-07-20")
.add_entry(entry("MND-001", 7500))
.build_xml();
assert!(xml.contains("urn:iso:std:iso:20022:tech:xsd:pain.008.003.02"));
assert!(xml.contains("<MsgId>TEST-001</MsgId>"));
assert!(xml.contains("<NbOfTxs>1</NbOfTxs>"));
assert!(xml.contains("<InstdAmt Ccy=\"EUR\">75.00</InstdAmt>"));
assert!(xml.contains("<MndtId>MND-001</MndtId>"));
assert!(xml.contains("<SeqTp>RCUR</SeqTp>"));
assert!(xml.contains("<IBAN>NL91ABNA0417164300</IBAN>"));
assert!(xml.contains("<ReqdColltnDt>2026-07-20</ReqdColltnDt>"));
assert!(
xml.contains(" <NbOfTxs>1</NbOfTxs>"),
"NbOfTxs must be indented 6 spaces"
);
assert!(
xml.contains(" <InitgPty>"),
"InitgPty must be indented 6 spaces"
);
}
#[test]
fn frst_sequence_type_in_xml() {
let xml = Pain008Builder::new("Test", &de_iban())
.msg_id("T")
.sequence_type(SequenceType::Frst)
.add_entry(entry("M1", 1000))
.build_xml();
assert!(xml.contains("<SeqTp>FRST</SeqTp>"));
assert!(!xml.contains("RCUR"));
}
#[test]
fn ooff_sequence_type_in_xml() {
let xml = Pain008Builder::new("Test", &de_iban())
.msg_id("T")
.sequence_type(SequenceType::Ooff)
.add_entry(entry("M1", 1000))
.build_xml();
assert!(xml.contains("<SeqTp>OOFF</SeqTp>"));
}
#[test]
fn ctrl_sum_equals_sum_of_entries() {
let xml = Pain008Builder::new("Test", &de_iban())
.msg_id("TEST-002")
.collection_date("2026-07-20")
.add_entry(entry("M1", 5_000))
.add_entry(entry("M2", 7_500))
.build_xml();
assert!(xml.contains("<CtrlSum>125.00</CtrlSum>"));
assert!(xml.contains("<NbOfTxs>2</NbOfTxs>"));
}
#[test]
fn no_f64_rounding() {
let builder = Pain008Builder::new("Test", &de_iban())
.add_entry(entry("M1", 10))
.add_entry(entry("M2", 20));
assert_eq!(builder.total_ct(), 30);
assert!(builder.build_xml().contains("<CtrlSum>0.30</CtrlSum>"));
}
#[test]
fn xml_escaping() {
let xml = Pain008Builder::new("Test & Co. <GmbH>", &de_iban())
.msg_id("ESC")
.build_xml();
assert!(xml.contains("Test & Co. <GmbH>"));
}
#[test]
fn entry_with_bic_and_description() {
let bic = "ABNANL2A".parse::<Bic>().unwrap();
let e = DirectDebitEntry::new("MND", "2024-01-01", "A", nl_iban(), 100, "E2E")
.with_bic(bic.clone())
.with_description("Test payment");
assert_eq!(e.debtor_bic.as_ref().map(Bic::as_str), Some("ABNANL2A"));
assert_eq!(e.description.as_deref(), Some("Test payment"));
}
#[test]
fn sequence_type_from_str() {
assert_eq!("FRST".parse::<SequenceType>().unwrap(), SequenceType::Frst);
assert_eq!("rcur".parse::<SequenceType>().unwrap(), SequenceType::Rcur);
assert_eq!("OOFF".parse::<SequenceType>().unwrap(), SequenceType::Ooff);
assert!("INVALID".parse::<SequenceType>().is_err());
}
#[test]
fn sequence_type_display() {
assert_eq!(SequenceType::Frst.to_string(), "FRST");
assert_eq!(SequenceType::Ooff.to_string(), "OOFF");
}
#[test]
fn days_to_ymd_epoch() {
assert_eq!(days_to_ymd(0), (1970, 1, 1));
}
#[test]
fn days_to_ymd_known_date() {
let days = (2026 - 1970) * 365
+ (1970..2026)
.filter(|y| y % 4 == 0 && (y % 100 != 0 || y % 400 == 0))
.count() as u64
+ 31
+ 28
+ 31
+ 30
+ 31
+ 30
+ 13
- 1; assert_eq!(days_to_ymd(days), (2026, 7, 13));
}
#[test]
fn creditor_scheme_id_in_xml_when_set() {
use crate::creditor_id::validate_creditor_id;
let ci = validate_creditor_id("DE74ZZZ09999999999").unwrap();
let xml = Pain008Builder::new("Test GmbH", &de_iban())
.msg_id("CI-TEST")
.creditor_id(ci)
.add_entry(entry("MND-CI", 5000))
.build_xml();
assert!(
xml.contains("<CdtrSchmeId>"),
"CdtrSchmeId must be present when creditor_id set"
);
assert!(
xml.contains("DE74ZZZ09999999999"),
"CI value must appear in XML"
);
assert!(
xml.contains("<Prtry>SEPA</Prtry>"),
"Scheme name must be SEPA"
);
}
#[test]
fn no_creditor_scheme_id_when_not_set() {
let xml = Pain008Builder::new("Test", &de_iban())
.msg_id("NO-CI")
.add_entry(entry("MND-1", 1000))
.build_xml();
assert!(
!xml.contains("<CdtrSchmeId>"),
"CdtrSchmeId must be absent when creditor_id not set"
);
}
#[test]
fn chrgbr_slev_always_present() {
let xml = Pain008Builder::new("Test", &de_iban())
.msg_id("CHRGBR")
.add_entry(entry("M1", 1000))
.build_xml();
assert!(
xml.contains("<ChrgBr>SLEV</ChrgBr>"),
"ChrgBr SLEV is mandatory per EPC SDD Rulebook"
);
}
#[test]
fn core_scheme_default() {
let xml = Pain008Builder::new("Test", &de_iban())
.add_entry(entry("M1", 1000))
.build_xml();
assert!(xml.contains("<Cd>CORE</Cd>"));
assert!(!xml.contains("<Cd>B2B</Cd>"));
}
#[test]
fn b2b_scheme() {
let xml = Pain008Builder::new("Test", &de_iban())
.scheme(DirectDebitScheme::B2b)
.add_entry(entry("M1", 1000))
.build_xml();
assert!(xml.contains("<Cd>B2B</Cd>"));
assert!(!xml.contains("<Cd>CORE</Cd>"));
assert!(xml.contains("<ChrgBr>SLEV</ChrgBr>"));
}
#[test]
fn direct_debit_scheme_display() {
assert_eq!(DirectDebitScheme::Core.to_string(), "CORE");
assert_eq!(DirectDebitScheme::B2b.to_string(), "B2B");
}
#[test]
fn write_xml_to_io_matches_build_xml() {
let xml = Pain008Builder::new("Test GmbH", &de_iban())
.msg_id("IO-TEST")
.add_entry(entry("M1", 5000))
.build_xml();
let mut buf: Vec<u8> = Vec::new();
Pain008Builder::new("Test GmbH", &de_iban())
.msg_id("IO-TEST")
.add_entry(entry("M1", 5000))
.write_xml_to_io(&mut buf)
.unwrap();
assert_eq!(xml, String::from_utf8(buf).unwrap());
}
#[test]
fn xml_escape_in_name_via_write_escaped() {
let xml = Pain008Builder::new("AT&T \"Corp\" <GmbH>", &de_iban())
.msg_id("ESC2")
.add_entry(entry("M1", 100))
.build_xml();
assert!(xml.contains("AT&T "Corp" <GmbH>"));
}
}