use std::str::FromStr;
use crate::address::PostalAddress;
use crate::bic::BicPattern;
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, accumulate_control_sum, check_amount, check_id, check_name, truncate_chars,
};
use crate::{Bic, Currency, 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]
pub const fn bic_pattern(self) -> BicPattern {
match self {
Self::IsoV9 => BicPattern::Alphanumeric,
Self::IsoV3 | Self::DkV2_7 => BicPattern::LettersOnly,
}
}
#[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 CreditTransferKind {
#[default]
Standard,
Instant,
OneLegOutInstant,
}
impl CreditTransferKind {
#[inline]
#[must_use]
pub const fn service_level(self) -> &'static str {
match self {
Self::Standard | Self::Instant => "SEPA",
Self::OneLegOutInstant => "EOLO",
}
}
#[inline]
#[must_use]
pub const fn local_instrument(self) -> Option<&'static str> {
match self {
Self::Standard => None,
Self::Instant | Self::OneLegOutInstant => Some("INST"),
}
}
#[inline]
#[must_use]
pub const fn is_instant(self) -> bool {
matches!(self, Self::Instant | Self::OneLegOutInstant)
}
#[inline]
#[must_use]
pub const fn default_charge_bearer(self) -> ChargeBearer {
match self {
Self::Standard | Self::Instant => ChargeBearer::Slev,
Self::OneLegOutInstant => ChargeBearer::Shar,
}
}
#[inline]
#[must_use]
pub const fn allows_charge_bearer(self, bearer: ChargeBearer) -> bool {
match self {
Self::Standard | Self::Instant => matches!(bearer, ChargeBearer::Slev),
Self::OneLegOutInstant => !matches!(bearer, ChargeBearer::Slev),
}
}
#[inline]
#[must_use]
pub const fn allows_non_euro_amount(self) -> bool {
matches!(self, Self::OneLegOutInstant)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ChargeBearer {
#[default]
Slev,
Cred,
Debt,
Shar,
}
impl ChargeBearer {
#[inline]
#[must_use]
pub const fn as_code(self) -> &'static str {
match self {
Self::Slev => "SLEV",
Self::Cred => "CRED",
Self::Debt => "DEBT",
Self::Shar => "SHAR",
}
}
}
impl std::fmt::Display for ChargeBearer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
#[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 currency: Option<Currency>,
pub non_euro_leg_currency: Option<Currency>,
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(),
currency: None,
non_euro_leg_currency: None,
creditor_bic: None,
creditor_address: None,
remittance: None,
ultimate_debtor: None,
ultimate_creditor: None,
purpose: None,
}
}
#[must_use]
pub fn with_currency(mut self, currency: Currency) -> Self {
self.currency = Some(currency);
self
}
#[must_use]
pub fn with_non_euro_leg_currency(mut self, currency: Currency) -> Self {
self.non_euro_leg_currency = Some(currency);
self
}
#[must_use]
pub fn effective_currency(&self) -> Currency {
self.currency.unwrap_or(Currency::EUR)
}
#[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,
kind: CreditTransferKind,
charge_bearer: Option<ChargeBearer>,
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,
execution: impl Into<ExecutionMoment>,
) -> Self {
Self {
payment_info_id: None,
debtor_name: debtor_name.into(),
debtor_iban: debtor_iban.clone(),
debtor_bic: None,
debtor_address: None,
execution: execution.into(),
kind: CreditTransferKind::Standard,
charge_bearer: 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 const fn scheme(&self) -> CreditTransferKind {
self.kind
}
#[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 kind(mut self, kind: CreditTransferKind) -> Self {
self.kind = kind;
self
}
#[must_use]
pub fn charge_bearer(mut self, bearer: ChargeBearer) -> Self {
self.charge_bearer = Some(bearer);
self
}
#[must_use]
pub const fn effective_charge_bearer(&self) -> ChargeBearer {
match self.charge_bearer {
Some(b) => b,
None => self.kind.default_charge_bearer(),
}
}
#[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>, msg_id: impl Into<String>) -> Self {
Self {
initiating_party: initiating_party.into(),
msg_id: msg_id.into(),
created_at: None,
schema: CreditTransferSchema::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: 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 message_id(&self) -> &str {
&self.msg_id
}
#[must_use]
pub const fn schema_version(&self) -> CreditTransferSchema {
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(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_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 check_instrument_and_timing(
g: &CreditTransferGroup,
schema: CreditTransferSchema,
) -> Result<(), ValidationError> {
let unsupported = |feature| ValidationError::UnsupportedBySchema {
feature,
schema: schema.message_id(),
};
if g.kind.local_instrument().is_some() && !schema.supports_local_instrument() {
return Err(unsupported("PmtTpInf/LclInstrm (SCT Inst)"));
}
if g.kind == CreditTransferKind::OneLegOutInstant && schema != CreditTransferSchema::IsoV9 {
return Err(unsupported("PmtTpInf/SvcLvl/Cd = EOLO (OCT Inst)"));
}
let bearer = g.effective_charge_bearer();
if !g.kind.allows_charge_bearer(bearer) {
return Err(ValidationError::ChargeBearerNotAllowed {
bearer: bearer.as_code(),
scheme: g.kind.service_level(),
});
}
let ExecutionMoment::At(moment) = g.execution else {
return Ok(());
};
if !schema.supports_execution_time() {
return Err(unsupported("ReqdExctnDt/DtTm (timed execution)"));
}
if !g.kind.is_instant() {
return Err(ValidationError::Requires {
feature: "ReqdExctnDt/DtTm (timed execution)",
requires: "an instant scheme — CreditTransferKind::Instant or ::OneLegOutInstant",
});
}
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(bic) = &g.debtor_bic {
self.check_bic_supported("DbtrAgt/FinInstnId", bic).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);
self.validate_entry(at, g, e)?;
*total = accumulate_control_sum(*total, e.amount_ct).at(at)?;
}
Ok(())
}
fn validate_entry(
&self,
at: Location,
g: &CreditTransferGroup,
e: &CreditTransferEntry,
) -> Result<(), BuildError> {
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)?;
if !g.kind.allows_non_euro_amount() {
if e.currency.is_some_and(|c| !c.is_euro()) {
return Err(BuildError {
location: at,
kind: ValidationError::Requires {
feature: "CdtTrfTxInf/Amt/InstdAmt @Ccy other than EUR",
requires: "CreditTransferKind::OneLegOutInstant",
},
});
}
if e.non_euro_leg_currency.is_some() {
return Err(BuildError {
location: at,
kind: ValidationError::Requires {
feature: "InstrForCdtrAgt/InstrInf (AT-T020)",
requires: "CreditTransferKind::OneLegOutInstant",
},
});
}
}
check_name(
"Cdtr/Nm",
&self.charset.apply("Cdtr/Nm", &e.creditor_name).at(at)?,
)
.at(at)?;
if let Some(bic) = &e.creditor_bic {
self.check_bic_supported("CdtrAgt/FinInstnId", bic).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)?;
}
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())
)?;
writeln!(
w,
" <PmtTpInf>\n <SvcLvl><Cd>{}</Cd></SvcLvl>",
g.kind.service_level()
)?;
if let Some(code) = g.kind.local_instrument() {
writeln!(w, " <LclInstrm><Cd>{code}</Cd></LclInstrm>")?;
}
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)?;
}
writeln!(w, " <ChrgBr>{}</ChrgBr>", g.effective_charge_bearer())?;
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)?;
write!(
w,
"</EndToEndId>\n </PmtId>\n <Amt><InstdAmt Ccy=\"{}\">",
e.effective_currency()
)?;
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(currency) = &e.non_euro_leg_currency {
writeln!(
w,
" <InstrForCdtrAgt><InstrInf>{currency}</InstrInf></InstrForCdtrAgt>"
)?;
}
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, "CT-001").add_group(
CreditTransferGroup::new(name, &de_iban(), 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", "CT-MULTI")
.add_group(
CreditTransferGroup::new("Acme GmbH", &de_iban(), d("2026-07-20"))
.add_entry(entry(10_000)),
)
.add_group(
CreditTransferGroup::new("Acme GmbH", &nl_iban(), 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);
let b = (0..3).fold(b, |b, _| {
b.add_group(
CreditTransferGroup::new("Acme", &de_iban(), 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(), d("2026-07-20"))
.payment_info_id(id)
.add_entry(entry(100))
};
let err = Pain001Builder::new("Acme", "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", "CT-OK")
.add_group(g("PMT-1"))
.add_group(g("PMT-2"))
.build()
.is_ok()
);
}
#[test]
fn the_message_id_is_the_callers_and_is_validated() {
assert!(matches!(
Pain001Builder::new("Acme", "X".repeat(36))
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), d("2026-07-20"))
.add_entry(entry(100))
)
.build()
.unwrap_err()
.kind,
ValidationError::TooLong {
field: "GrpHdr/MsgId",
..
}
));
}
#[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", "CT-DK")
.schema(CreditTransferSchema::DkV2_7)
.add_group(
CreditTransferGroup::new("Test", &de_iban(), 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", "CT-INST")
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), d("2026-07-20"))
.kind(CreditTransferKind::Instant)
.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", "CT-ADR")
.add_group(
CreditTransferGroup::new("Acme GmbH", &de_iban(), 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", "CT-DK-ADR")
.schema(CreditTransferSchema::DkV2_7)
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), 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", "CT-ADR-BAD")
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), 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", "CT-OPT")
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), 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", "CT-ULT").add_group(
CreditTransferGroup::new("Acme", &de_iban(), 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", "E").build(),
Err(BuildError::message(ValidationError::EmptyBatch))
);
assert_eq!(
Pain001Builder::new("Acme", "E")
.add_group(CreditTransferGroup::new(
"Acme",
&de_iban(),
d("2026-07-20")
))
.build(),
Err(BuildError::group(0, ValidationError::EmptyBatch))
);
}
#[test]
fn validation_rejects_bad_fields() {
let g = || CreditTransferGroup::new("Acme", &de_iban(), d("2026-07-20"));
let b = || Pain001Builder::new("Acme", "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(
Pain001Builder::new("Acme", "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(), d("2026-07-20"));
let err = Pain001Builder::new("Acme", "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", "CT-V3")
.schema(CreditTransferSchema::IsoV3)
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), 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", "CT-DK-INST")
.schema(CreditTransferSchema::DkV2_7)
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), d("2026-07-20"))
.kind(CreditTransferKind::Instant)
.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", "CT-INST")
.schema(schema)
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), d("2026-07-20"))
.kind(CreditTransferKind::Instant)
.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", "CT").add_group(
CreditTransferGroup::new("Acme", &de_iban(), 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", "CT-UML")
.add_group(
CreditTransferGroup::new("Müller & Söhne GmbH", &de_iban(), 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", "CT-EMPTY")
.write_to(&mut empty)
.is_err()
);
assert_eq!(empty.len(), 0);
}
#[test]
fn oct_inst_emits_eolo_inst_and_a_non_slev_charge_bearer() {
let xml = Pain001Builder::new("Acme GmbH", "OCT-001")
.add_group(
CreditTransferGroup::new("Acme GmbH", &de_iban(), d("2026-07-20"))
.kind(CreditTransferKind::OneLegOutInstant)
.add_entry(entry(12_000)),
)
.build()
.unwrap();
assert!(xml.contains("<SvcLvl><Cd>EOLO</Cd></SvcLvl>"), "{xml}");
assert!(
xml.contains("<LclInstrm><Cd>INST</Cd></LclInstrm>"),
"{xml}"
);
assert!(xml.contains("<ChrgBr>SHAR</ChrgBr>"), "{xml}");
assert!(!xml.contains("SLEV"), "{xml}");
assert!(
xml.contains(r#"<InstdAmt Ccy="EUR">120.00</InstdAmt>"#),
"{xml}"
);
}
#[test]
fn oct_inst_carries_a_non_euro_amount_and_at_t020() {
let xml = Pain001Builder::new("Acme GmbH", "OCT-002")
.add_group(
CreditTransferGroup::new("Acme GmbH", &de_iban(), d("2026-07-20"))
.kind(CreditTransferKind::OneLegOutInstant)
.charge_bearer(ChargeBearer::Cred)
.add_entry(
entry(12_000)
.with_currency("USD".parse().unwrap())
.with_non_euro_leg_currency("USD".parse().unwrap()),
),
)
.build()
.unwrap();
assert!(
xml.contains(r#"<InstdAmt Ccy="USD">120.00</InstdAmt>"#),
"{xml}"
);
assert!(
xml.contains("<InstrForCdtrAgt><InstrInf>USD</InstrInf></InstrForCdtrAgt>"),
"{xml}"
);
assert!(xml.contains("<ChrgBr>CRED</ChrgBr>"), "{xml}");
}
#[test]
fn the_sepa_schemes_keep_slev_and_euro() {
for kind in [CreditTransferKind::Standard, CreditTransferKind::Instant] {
let xml = Pain001Builder::new("Acme", "CT-1")
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), d("2026-07-20"))
.kind(kind)
.add_entry(entry(12_000)),
)
.build()
.unwrap();
assert!(xml.contains("<SvcLvl><Cd>SEPA</Cd></SvcLvl>"), "{kind:?}");
assert!(xml.contains("<ChrgBr>SLEV</ChrgBr>"), "{kind:?}");
assert_eq!(
kind == CreditTransferKind::Instant,
xml.contains("<LclInstrm><Cd>INST</Cd></LclInstrm>"),
"{kind:?}"
);
}
}
#[test]
fn a_rule_from_the_wrong_scheme_is_rejected_rather_than_emitted() {
let group = |kind| CreditTransferGroup::new("Acme", &de_iban(), d("2026-07-20")).kind(kind);
let build =
|g: CreditTransferGroup| Pain001Builder::new("Acme", "CT-X").add_group(g).build();
assert_eq!(
build(
group(CreditTransferKind::OneLegOutInstant)
.charge_bearer(ChargeBearer::Slev)
.add_entry(entry(100))
)
.unwrap_err()
.kind,
ValidationError::ChargeBearerNotAllowed {
bearer: "SLEV",
scheme: "EOLO",
}
);
assert_eq!(
build(
group(CreditTransferKind::Standard)
.charge_bearer(ChargeBearer::Shar)
.add_entry(entry(100))
)
.unwrap_err()
.kind,
ValidationError::ChargeBearerNotAllowed {
bearer: "SHAR",
scheme: "SEPA",
}
);
assert_eq!(
build(
group(CreditTransferKind::Instant)
.add_entry(entry(100).with_currency("USD".parse().unwrap()))
)
.unwrap_err()
.kind,
ValidationError::Requires {
feature: "CdtTrfTxInf/Amt/InstdAmt @Ccy other than EUR",
requires: "CreditTransferKind::OneLegOutInstant",
}
);
assert_eq!(
build(
group(CreditTransferKind::Standard)
.add_entry(entry(100).with_non_euro_leg_currency("USD".parse().unwrap()))
)
.unwrap_err()
.kind,
ValidationError::Requires {
feature: "InstrForCdtrAgt/InstrInf (AT-T020)",
requires: "CreditTransferKind::OneLegOutInstant",
}
);
assert!(
build(
group(CreditTransferKind::Standard)
.add_entry(entry(100).with_currency(crate::Currency::EUR))
)
.is_ok()
);
}
#[test]
fn oct_inst_needs_the_2019_message_version() {
for schema in [CreditTransferSchema::IsoV3, CreditTransferSchema::DkV2_7] {
let err = Pain001Builder::new("Acme", "CT-OCT")
.schema(schema)
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), d("2026-07-20"))
.kind(CreditTransferKind::OneLegOutInstant)
.add_entry(entry(100)),
)
.build()
.unwrap_err();
assert!(
matches!(err.kind, ValidationError::UnsupportedBySchema { .. }),
"{schema:?} must refuse EOLO, got {err:?}"
);
}
}
#[test]
fn a_timed_execution_follows_the_scheme_not_the_local_instrument() {
let at: IsoDateTime = "2026-07-20T11:00:00Z".parse().unwrap();
let build = |kind| {
Pain001Builder::new("Acme", "CT-T")
.add_group(
CreditTransferGroup::new("Acme", &de_iban(), at)
.kind(kind)
.add_entry(entry(100)),
)
.build()
};
assert!(build(CreditTransferKind::Instant).is_ok());
assert!(build(CreditTransferKind::OneLegOutInstant).is_ok());
assert!(build(CreditTransferKind::Standard).is_err());
}
#[test]
fn the_control_sum_is_bounded_by_the_schema_not_by_i64() {
use crate::validate::{MAX_CTRL_SUM_CT, accumulate_control_sum};
assert_eq!(accumulate_control_sum(0, 100), Ok(100));
assert!(accumulate_control_sum(MAX_CTRL_SUM_CT - 1, 1).is_ok());
assert!(accumulate_control_sum(MAX_CTRL_SUM_CT, 1).is_err());
let digits = |ct| {
crate::ct_to_eur_str(ct)
.chars()
.filter(char::is_ascii_digit)
.count()
};
assert_eq!(
digits(MAX_CTRL_SUM_CT),
18,
"CtrlSum is a DecimalNumber with totalDigits=18"
);
assert_eq!(
digits(i64::MAX),
19,
"i64::MAX is a 19-digit CtrlSum — one past what the schema allows, \
which is why the bound cannot be the integer type's"
);
}
}