use std::any::Any;
use super::xml_scan::{extract_all_elements, extract_attribute, extract_element};
use super::SchemeValidator;
use crate::error::{Severity, ValidationError, ValidationResult};
pub struct SepaValidator;
impl SepaValidator {
pub fn new() -> Self {
Self
}
}
impl Default for SepaValidator {
fn default() -> Self {
Self::new()
}
}
fn is_sepa_char(c: char) -> bool {
matches!(c,
'A'..='Z'
| 'a'..='z'
| '0'..='9'
| '/' | '-' | '?' | ':' | '(' | ')' | '.' | ',' | '\'' | '+' | ' '
) || ('\u{00C0}'..='\u{00FF}').contains(&c)
}
pub fn is_sepa_charset(s: &str) -> bool {
s.chars().all(is_sepa_char)
}
const CHARSET_TAGS: &[&str] = &["Nm", "Ustrd", "StrtNm", "TwnNm"];
impl SchemeValidator for SepaValidator {
fn name(&self) -> &'static str {
"SEPA"
}
fn supported_messages(&self) -> &[&str] {
&["pacs.008", "pacs.002", "pain.001"]
}
fn validate(&self, xml: &str, message_type: &str) -> ValidationResult {
let short_type = super::short_message_type(message_type);
if !self.supported_messages().contains(&short_type.as_str()) {
return ValidationResult::default();
}
let mut errors: Vec<ValidationError> = Vec::new();
if short_type != "pacs.008" {
return ValidationResult::new(errors);
}
if let Some(ccy) = extract_attribute(xml, "IntrBkSttlmAmt", "Ccy") {
if ccy != "EUR" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
Severity::Error,
"SEPA_CURRENCY",
format!("SEPA only accepts EUR transactions; found currency \"{ccy}\""),
));
}
}
if let Some(chrg_br) = extract_element(xml, "ChrgBr") {
if chrg_br != "SLEV" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
Severity::Error,
"SEPA_CHRGBR",
format!("SEPA SCT requires ChrgBr = \"SLEV\", got \"{chrg_br}\""),
));
}
} else {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
Severity::Error,
"SEPA_CHRGBR_REQUIRED",
"SEPA SCT requires ChrgBr = \"SLEV\"",
));
}
if let Some(sttlm_mtd) = extract_element(xml, "SttlmMtd") {
if sttlm_mtd != "CLRG" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd",
Severity::Error,
"SEPA_STTLM_MTD",
format!("SEPA requires SttlmMtd = \"CLRG\", got \"{sttlm_mtd}\""),
));
}
}
if let Some(nb) = extract_element(xml, "NbOfTxs") {
if nb != "1" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs",
Severity::Error,
"SEPA_SINGLE_TX",
format!(
"SEPA requires one transaction per group (NbOfTxs = \"1\"), got \"{nb}\""
),
));
}
}
check_name(xml, "Dbtr", 70, &mut errors, "SEPA_DBTR_NM");
check_name(xml, "Cdtr", 70, &mut errors, "SEPA_CDTR_NM");
if let Some(e2e) = extract_element(xml, "EndToEndId") {
if e2e.chars().count() > 35 {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
Severity::Error,
"SEPA_E2E_LENGTH",
format!(
"EndToEndId must be at most 35 characters; got {} characters",
e2e.chars().count()
),
));
}
}
let ustrd_total: usize = extract_all_elements(xml, "Ustrd")
.iter()
.map(|s| s.chars().count())
.sum();
if ustrd_total > 140 {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd",
Severity::Error,
"SEPA_USTRD_LENGTH",
format!(
"RmtInf/Ustrd total length must not exceed 140 characters; got {ustrd_total}"
),
));
}
if let Some(amt_str) = extract_element(xml, "IntrBkSttlmAmt") {
Self::validate_sepa_amount(
amt_str,
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt",
&mut errors,
);
}
let ibans = extract_all_elements(xml, "IBAN");
if ibans.is_empty() {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf",
Severity::Error,
"SEPA_IBAN_REQUIRED",
"SEPA requires IBAN for both debtor and creditor accounts; none found",
));
} else if ibans.len() < 2 {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf",
Severity::Warning,
"SEPA_IBAN_BOTH",
"SEPA requires IBAN for both debtor and creditor; only one found",
));
}
for tag in CHARSET_TAGS {
for value in extract_all_elements(xml, tag) {
if !is_sepa_charset(value) {
let bad: String = value.chars().filter(|&c| !is_sepa_char(c)).collect();
errors.push(ValidationError::new(
format!("//{tag}"),
Severity::Error,
"SEPA_CHARSET",
format!(
"Field <{tag}> contains characters outside the SEPA restricted \
Latin character set: {bad:?}"
),
));
}
}
}
ValidationResult::new(errors)
}
fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option<ValidationResult> {
use mx20022_model::generated::pacs::pacs_008_001_13;
let short_type = super::short_message_type(message_type);
if !self.supported_messages().contains(&short_type.as_str()) {
return None;
}
if short_type != "pacs.008" {
return None;
}
let doc = msg.downcast_ref::<pacs_008_001_13::Document>()?;
Some(self.validate_pacs008_typed(doc))
}
}
impl SepaValidator {
fn validate_sepa_amount(amt_str: &str, path: &str, errors: &mut Vec<ValidationError>) {
let decimals = amt_str.find('.').map_or(0, |dot| amt_str.len() - dot - 1);
if decimals > 2 {
errors.push(ValidationError::new(
path,
Severity::Error,
"SEPA_AMOUNT_DECIMALS",
format!("SEPA amounts must have at most 2 decimal places; got \"{amt_str}\""),
));
}
match super::common::parse_amount_cents_lenient(amt_str) {
Some(cents) => {
if cents < 1 {
errors.push(ValidationError::new(
path,
Severity::Error,
"SEPA_AMOUNT_MIN",
format!("SEPA minimum amount is 0.01 EUR; got \"{amt_str}\""),
));
}
if cents > 99_999_999_999 {
errors.push(ValidationError::new(
path,
Severity::Error,
"SEPA_AMOUNT_MAX",
format!("SEPA maximum amount is 999,999,999.99 EUR; got \"{amt_str}\""),
));
}
}
None => {
errors.push(ValidationError::new(
path,
Severity::Error,
"SEPA_AMOUNT_FORMAT",
format!("Cannot parse amount as a number: \"{amt_str}\""),
));
}
}
}
fn check_sepa_name(name: &str, errors: &mut Vec<ValidationError>) {
if !is_sepa_charset(name) {
let bad: String = name.chars().filter(|&c| !is_sepa_char(c)).collect();
errors.push(ValidationError::new(
"//Nm",
Severity::Error,
"SEPA_CHARSET",
format!(
"Field <Nm> contains characters outside the SEPA restricted \
Latin character set: {bad:?}"
),
));
}
}
#[allow(clippy::unused_self)]
fn validate_pacs008_typed(
&self,
doc: &mx20022_model::generated::pacs::pacs_008_001_13::Document,
) -> ValidationResult {
use mx20022_model::generated::pacs::pacs_008_001_13::{
AccountIdentification4Choice, ChargeBearerType1Code, SettlementMethod1Code,
};
let mut errors: Vec<ValidationError> = Vec::new();
let msg = &doc.fi_to_fi_cstmr_cdt_trf;
if msg.grp_hdr.sttlm_inf.sttlm_mtd != SettlementMethod1Code::Clrg {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd",
Severity::Error,
"SEPA_STTLM_MTD",
format!(
"SEPA requires SttlmMtd = \"CLRG\", got {:?}",
msg.grp_hdr.sttlm_inf.sttlm_mtd
),
));
}
if msg.grp_hdr.nb_of_txs.0 != "1" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs",
Severity::Error,
"SEPA_SINGLE_TX",
format!(
"SEPA requires one transaction per group (NbOfTxs = \"1\"), got \"{}\"",
msg.grp_hdr.nb_of_txs.0
),
));
}
for tx in &msg.cdt_trf_tx_inf {
let ccy = &tx.intr_bk_sttlm_amt.ccy.0;
if ccy != "EUR" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
Severity::Error,
"SEPA_CURRENCY",
format!("SEPA only accepts EUR transactions; found currency \"{ccy}\""),
));
}
if tx.chrg_br != ChargeBearerType1Code::Slev {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
Severity::Error,
"SEPA_CHRGBR",
format!("SEPA SCT requires ChrgBr = \"SLEV\", got {:?}", tx.chrg_br),
));
}
match &tx.dbtr.nm {
None => {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm",
Severity::Error,
"SEPA_DBTR_NM",
"Dbtr/Nm is required for SEPA",
));
}
Some(nm) if nm.0.chars().count() > 70 => {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm",
Severity::Error,
"SEPA_DBTR_NM",
format!(
"Dbtr/Nm must be at most 70 characters; got {} characters",
nm.0.chars().count()
),
));
}
Some(_) => {}
}
match &tx.cdtr.nm {
None => {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm",
Severity::Error,
"SEPA_CDTR_NM",
"Cdtr/Nm is required for SEPA",
));
}
Some(nm) if nm.0.chars().count() > 70 => {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm",
Severity::Error,
"SEPA_CDTR_NM",
format!(
"Cdtr/Nm must be at most 70 characters; got {} characters",
nm.0.chars().count()
),
));
}
Some(_) => {}
}
if let Some(rmt_inf) = &tx.rmt_inf {
let ustrd_total: usize = rmt_inf.ustrd.iter().map(|u| u.0.chars().count()).sum();
if ustrd_total > 140 {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd",
Severity::Error,
"SEPA_USTRD_LENGTH",
format!(
"RmtInf/Ustrd total length must not exceed 140 characters; got {ustrd_total}"
),
));
}
for ustrd in &rmt_inf.ustrd {
if !is_sepa_charset(&ustrd.0) {
let bad: String = ustrd.0.chars().filter(|&c| !is_sepa_char(c)).collect();
errors.push(ValidationError::new(
"//Ustrd",
Severity::Error,
"SEPA_CHARSET",
format!(
"Field <Ustrd> contains characters outside the SEPA restricted \
Latin character set: {bad:?}"
),
));
}
}
}
let amt_str: &str = &tx.intr_bk_sttlm_amt.value.0;
Self::validate_sepa_amount(
amt_str,
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt",
&mut errors,
);
if let Some(nm) = &tx.dbtr.nm {
Self::check_sepa_name(&nm.0, &mut errors);
}
if let Some(nm) = &tx.cdtr.nm {
Self::check_sepa_name(&nm.0, &mut errors);
}
let has_dbtr_iban = tx.dbtr_acct.as_ref().is_some_and(|acct| {
acct.id.as_ref().is_some_and(|choice| {
matches!(choice.inner, AccountIdentification4Choice::IBAN(_))
})
});
let has_cdtr_iban = tx.cdtr_acct.as_ref().is_some_and(|acct| {
acct.id.as_ref().is_some_and(|choice| {
matches!(choice.inner, AccountIdentification4Choice::IBAN(_))
})
});
if !has_dbtr_iban && !has_cdtr_iban {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf",
Severity::Error,
"SEPA_IBAN_REQUIRED",
"SEPA requires IBAN for both debtor and creditor accounts; none found",
));
} else if !has_dbtr_iban || !has_cdtr_iban {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf",
Severity::Warning,
"SEPA_IBAN_BOTH",
"SEPA requires IBAN for both debtor and creditor; only one found",
));
}
}
ValidationResult::new(errors)
}
}
fn check_name(
xml: &str,
parent_tag: &str,
max_len: usize,
errors: &mut Vec<ValidationError>,
rule_id: &str,
) {
let path = format!("/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/{parent_tag}");
super::common::check_name_in_parent(
xml,
parent_tag,
Some(max_len),
&path,
rule_id,
"SEPA",
errors,
true,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_is_sepa() {
assert_eq!(SepaValidator::new().name(), "SEPA");
}
#[test]
fn supports_pacs008() {
let v = SepaValidator::new();
assert!(v.supported_messages().contains(&"pacs.008"));
}
#[test]
fn unsupported_message_returns_empty() {
let v = SepaValidator::new();
let result = v.validate("<xml/>", "pacs.009.001.10");
assert!(result.errors.is_empty());
}
#[test]
fn sepa_charset_ascii_allowed() {
assert!(is_sepa_charset("Alice Smith / 123"));
}
#[test]
fn sepa_charset_diacritics_allowed() {
assert!(is_sepa_charset("Müller")); }
#[test]
fn sepa_charset_control_chars_rejected() {
assert!(!is_sepa_charset("Alice\x01Smith"));
}
#[test]
fn sepa_charset_cyrillic_rejected() {
assert!(!is_sepa_charset("Алиса")); }
fn sepa_xml_with_amount(amount: &str) -> String {
format!(
r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.13">
<FIToFICstmrCdtTrf>
<GrpHdr><NbOfTxs>1</NbOfTxs><SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr>
<CdtTrfTxInf>
<IntrBkSttlmAmt Ccy="EUR">{amount}</IntrBkSttlmAmt>
<ChrgBr>SLEV</ChrgBr>
<Dbtr><Nm>Alice</Nm></Dbtr>
<Cdtr><Nm>Bob</Nm></Cdtr>
<DbtrAgt><FinInstnId><BICFI>BANKDEFF</BICFI></FinInstnId></DbtrAgt>
<CdtrAgt><FinInstnId><BICFI>BANKDEFF</BICFI></FinInstnId></CdtrAgt>
<DbtrAcct><Id><IBAN>DE89370400440532013000</IBAN></Id></DbtrAcct>
<CdtrAcct><Id><IBAN>DE89370400440532013000</IBAN></Id></CdtrAcct>
</CdtTrfTxInf>
</FIToFICstmrCdtTrf>
</Document>"#
)
}
fn has_error(result: &ValidationResult, code: &str) -> bool {
result.errors.iter().any(|e| e.rule_id == code)
}
#[test]
fn sepa_amount_at_max_boundary() {
let v = SepaValidator::new();
let xml = sepa_xml_with_amount("999999999.99");
let result = v.validate(&xml, "pacs.008.001.13");
assert!(
!has_error(&result, "SEPA_AMOUNT_MAX"),
"999999999.99 should be within SEPA max; errors: {:?}",
result.errors
);
}
#[test]
fn sepa_amount_just_under_max() {
let v = SepaValidator::new();
let xml = sepa_xml_with_amount("999999999.98");
let result = v.validate(&xml, "pacs.008.001.13");
assert!(!has_error(&result, "SEPA_AMOUNT_MAX"));
}
#[test]
fn sepa_amount_exceeds_max() {
let v = SepaValidator::new();
let xml = sepa_xml_with_amount("1000000000.00");
let result = v.validate(&xml, "pacs.008.001.13");
assert!(
has_error(&result, "SEPA_AMOUNT_MAX"),
"1000000000.00 should exceed SEPA max"
);
}
#[test]
fn sepa_amount_at_min_boundary() {
let v = SepaValidator::new();
let xml = sepa_xml_with_amount("0.01");
let result = v.validate(&xml, "pacs.008.001.13");
assert!(
!has_error(&result, "SEPA_AMOUNT_MIN"),
"0.01 should be within SEPA min"
);
}
#[test]
fn sepa_amount_below_min() {
let v = SepaValidator::new();
let xml = sepa_xml_with_amount("0.00");
let result = v.validate(&xml, "pacs.008.001.13");
assert!(
has_error(&result, "SEPA_AMOUNT_MIN"),
"0.00 should be below SEPA min"
);
}
}