1use std::any::Any;
17
18use super::xml_scan::{extract_all_elements, extract_attribute, extract_element};
19use super::SchemeValidator;
20use crate::error::{Severity, ValidationError, ValidationResult};
21
22pub struct SepaValidator;
35
36impl SepaValidator {
37 pub fn new() -> Self {
39 Self
40 }
41}
42
43impl Default for SepaValidator {
44 fn default() -> Self {
45 Self::new()
46 }
47}
48
49fn is_sepa_char(c: char) -> bool {
51 matches!(c,
52 'A'..='Z'
53 | 'a'..='z'
54 | '0'..='9'
55 | '/' | '-' | '?' | ':' | '(' | ')' | '.' | ',' | '\'' | '+' | ' '
56 ) || ('\u{00C0}'..='\u{00FF}').contains(&c)
57}
58
59pub fn is_sepa_charset(s: &str) -> bool {
65 s.chars().all(is_sepa_char)
66}
67
68const CHARSET_TAGS: &[&str] = &["Nm", "Ustrd", "StrtNm", "TwnNm"];
70
71impl SchemeValidator for SepaValidator {
72 fn name(&self) -> &'static str {
73 "SEPA"
74 }
75
76 fn supported_messages(&self) -> &[&str] {
77 &["pacs.008", "pacs.002", "pain.001"]
78 }
79
80 fn validate(&self, xml: &str, message_type: &str) -> ValidationResult {
81 let short_type = super::short_message_type(message_type);
82
83 if !self.supported_messages().contains(&short_type.as_str()) {
84 return ValidationResult::default();
85 }
86
87 let mut errors: Vec<ValidationError> = Vec::new();
88
89 if short_type != "pacs.008" {
91 return ValidationResult::new(errors);
92 }
93
94 if let Some(ccy) = extract_attribute(xml, "IntrBkSttlmAmt", "Ccy") {
96 if ccy != "EUR" {
97 errors.push(ValidationError::new(
98 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
99 Severity::Error,
100 "SEPA_CURRENCY",
101 format!("SEPA only accepts EUR transactions; found currency \"{ccy}\""),
102 ));
103 }
104 }
105
106 if let Some(chrg_br) = extract_element(xml, "ChrgBr") {
108 if chrg_br != "SLEV" {
109 errors.push(ValidationError::new(
110 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
111 Severity::Error,
112 "SEPA_CHRGBR",
113 format!("SEPA SCT requires ChrgBr = \"SLEV\", got \"{chrg_br}\""),
114 ));
115 }
116 } else {
117 errors.push(ValidationError::new(
118 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
119 Severity::Error,
120 "SEPA_CHRGBR_REQUIRED",
121 "SEPA SCT requires ChrgBr = \"SLEV\"",
122 ));
123 }
124
125 if let Some(sttlm_mtd) = extract_element(xml, "SttlmMtd") {
127 if sttlm_mtd != "CLRG" {
128 errors.push(ValidationError::new(
129 "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd",
130 Severity::Error,
131 "SEPA_STTLM_MTD",
132 format!("SEPA requires SttlmMtd = \"CLRG\", got \"{sttlm_mtd}\""),
133 ));
134 }
135 }
136
137 if let Some(nb) = extract_element(xml, "NbOfTxs") {
139 if nb != "1" {
140 errors.push(ValidationError::new(
141 "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs",
142 Severity::Error,
143 "SEPA_SINGLE_TX",
144 format!(
145 "SEPA requires one transaction per group (NbOfTxs = \"1\"), got \"{nb}\""
146 ),
147 ));
148 }
149 }
150
151 check_name(xml, "Dbtr", 70, &mut errors, "SEPA_DBTR_NM");
153 check_name(xml, "Cdtr", 70, &mut errors, "SEPA_CDTR_NM");
155
156 if let Some(e2e) = extract_element(xml, "EndToEndId") {
158 if e2e.chars().count() > 35 {
159 errors.push(ValidationError::new(
160 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
161 Severity::Error,
162 "SEPA_E2E_LENGTH",
163 format!(
164 "EndToEndId must be at most 35 characters; got {} characters",
165 e2e.chars().count()
166 ),
167 ));
168 }
169 }
170
171 let ustrd_total: usize = extract_all_elements(xml, "Ustrd")
173 .iter()
174 .map(|s| s.chars().count())
175 .sum();
176 if ustrd_total > 140 {
177 errors.push(ValidationError::new(
178 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd",
179 Severity::Error,
180 "SEPA_USTRD_LENGTH",
181 format!(
182 "RmtInf/Ustrd total length must not exceed 140 characters; got {ustrd_total}"
183 ),
184 ));
185 }
186
187 if let Some(amt_str) = extract_element(xml, "IntrBkSttlmAmt") {
189 Self::validate_sepa_amount(
190 amt_str,
191 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt",
192 &mut errors,
193 );
194 }
195
196 let ibans = extract_all_elements(xml, "IBAN");
199 if ibans.is_empty() {
200 errors.push(ValidationError::new(
201 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf",
202 Severity::Error,
203 "SEPA_IBAN_REQUIRED",
204 "SEPA requires IBAN for both debtor and creditor accounts; none found",
205 ));
206 } else if ibans.len() < 2 {
207 errors.push(ValidationError::new(
208 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf",
209 Severity::Warning,
210 "SEPA_IBAN_BOTH",
211 "SEPA requires IBAN for both debtor and creditor; only one found",
212 ));
213 }
214
215 for tag in CHARSET_TAGS {
217 for value in extract_all_elements(xml, tag) {
218 if !is_sepa_charset(value) {
219 let bad: String = value.chars().filter(|&c| !is_sepa_char(c)).collect();
221 errors.push(ValidationError::new(
222 format!("//{tag}"),
223 Severity::Error,
224 "SEPA_CHARSET",
225 format!(
226 "Field <{tag}> contains characters outside the SEPA restricted \
227 Latin character set: {bad:?}"
228 ),
229 ));
230 }
231 }
232 }
233
234 ValidationResult::new(errors)
235 }
236
237 fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option<ValidationResult> {
238 use mx20022_model::generated::pacs::pacs_008_001_13;
239
240 let short_type = super::short_message_type(message_type);
241 if !self.supported_messages().contains(&short_type.as_str()) {
242 return None;
243 }
244
245 if short_type != "pacs.008" {
246 return None;
247 }
248
249 let doc = msg.downcast_ref::<pacs_008_001_13::Document>()?;
250
251 Some(self.validate_pacs008_typed(doc))
252 }
253}
254
255impl SepaValidator {
256 fn validate_sepa_amount(amt_str: &str, path: &str, errors: &mut Vec<ValidationError>) {
258 let decimals = amt_str.find('.').map_or(0, |dot| amt_str.len() - dot - 1);
259 if decimals > 2 {
260 errors.push(ValidationError::new(
261 path,
262 Severity::Error,
263 "SEPA_AMOUNT_DECIMALS",
264 format!("SEPA amounts must have at most 2 decimal places; got \"{amt_str}\""),
265 ));
266 }
267 match super::common::parse_amount_cents_lenient(amt_str) {
268 Some(cents) => {
269 if cents < 1 {
270 errors.push(ValidationError::new(
271 path,
272 Severity::Error,
273 "SEPA_AMOUNT_MIN",
274 format!("SEPA minimum amount is 0.01 EUR; got \"{amt_str}\""),
275 ));
276 }
277 if cents > 99_999_999_999 {
278 errors.push(ValidationError::new(
279 path,
280 Severity::Error,
281 "SEPA_AMOUNT_MAX",
282 format!("SEPA maximum amount is 999,999,999.99 EUR; got \"{amt_str}\""),
283 ));
284 }
285 }
286 None => {
287 errors.push(ValidationError::new(
288 path,
289 Severity::Error,
290 "SEPA_AMOUNT_FORMAT",
291 format!("Cannot parse amount as a number: \"{amt_str}\""),
292 ));
293 }
294 }
295 }
296
297 fn check_sepa_name(name: &str, errors: &mut Vec<ValidationError>) {
299 if !is_sepa_charset(name) {
300 let bad: String = name.chars().filter(|&c| !is_sepa_char(c)).collect();
301 errors.push(ValidationError::new(
302 "//Nm",
303 Severity::Error,
304 "SEPA_CHARSET",
305 format!(
306 "Field <Nm> contains characters outside the SEPA restricted \
307 Latin character set: {bad:?}"
308 ),
309 ));
310 }
311 }
312
313 #[allow(clippy::unused_self)]
315 fn validate_pacs008_typed(
316 &self,
317 doc: &mx20022_model::generated::pacs::pacs_008_001_13::Document,
318 ) -> ValidationResult {
319 use mx20022_model::generated::pacs::pacs_008_001_13::{
320 AccountIdentification4Choice, ChargeBearerType1Code, SettlementMethod1Code,
321 };
322
323 let mut errors: Vec<ValidationError> = Vec::new();
324 let msg = &doc.fi_to_fi_cstmr_cdt_trf;
325
326 if msg.grp_hdr.sttlm_inf.sttlm_mtd != SettlementMethod1Code::Clrg {
328 errors.push(ValidationError::new(
329 "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd",
330 Severity::Error,
331 "SEPA_STTLM_MTD",
332 format!(
333 "SEPA requires SttlmMtd = \"CLRG\", got {:?}",
334 msg.grp_hdr.sttlm_inf.sttlm_mtd
335 ),
336 ));
337 }
338
339 if msg.grp_hdr.nb_of_txs.0 != "1" {
341 errors.push(ValidationError::new(
342 "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs",
343 Severity::Error,
344 "SEPA_SINGLE_TX",
345 format!(
346 "SEPA requires one transaction per group (NbOfTxs = \"1\"), got \"{}\"",
347 msg.grp_hdr.nb_of_txs.0
348 ),
349 ));
350 }
351
352 for tx in &msg.cdt_trf_tx_inf {
353 let ccy = &tx.intr_bk_sttlm_amt.ccy.0;
355 if ccy != "EUR" {
356 errors.push(ValidationError::new(
357 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
358 Severity::Error,
359 "SEPA_CURRENCY",
360 format!("SEPA only accepts EUR transactions; found currency \"{ccy}\""),
361 ));
362 }
363
364 if tx.chrg_br != ChargeBearerType1Code::Slev {
366 errors.push(ValidationError::new(
367 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
368 Severity::Error,
369 "SEPA_CHRGBR",
370 format!("SEPA SCT requires ChrgBr = \"SLEV\", got {:?}", tx.chrg_br),
371 ));
372 }
373
374 match &tx.dbtr.nm {
376 None => {
377 errors.push(ValidationError::new(
378 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm",
379 Severity::Error,
380 "SEPA_DBTR_NM",
381 "Dbtr/Nm is required for SEPA",
382 ));
383 }
384 Some(nm) if nm.0.chars().count() > 70 => {
385 errors.push(ValidationError::new(
386 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm",
387 Severity::Error,
388 "SEPA_DBTR_NM",
389 format!(
390 "Dbtr/Nm must be at most 70 characters; got {} characters",
391 nm.0.chars().count()
392 ),
393 ));
394 }
395 Some(_) => {}
396 }
397
398 match &tx.cdtr.nm {
400 None => {
401 errors.push(ValidationError::new(
402 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm",
403 Severity::Error,
404 "SEPA_CDTR_NM",
405 "Cdtr/Nm is required for SEPA",
406 ));
407 }
408 Some(nm) if nm.0.chars().count() > 70 => {
409 errors.push(ValidationError::new(
410 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm",
411 Severity::Error,
412 "SEPA_CDTR_NM",
413 format!(
414 "Cdtr/Nm must be at most 70 characters; got {} characters",
415 nm.0.chars().count()
416 ),
417 ));
418 }
419 Some(_) => {}
420 }
421
422 if let Some(rmt_inf) = &tx.rmt_inf {
428 let ustrd_total: usize = rmt_inf.ustrd.iter().map(|u| u.0.chars().count()).sum();
429 if ustrd_total > 140 {
430 errors.push(ValidationError::new(
431 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd",
432 Severity::Error,
433 "SEPA_USTRD_LENGTH",
434 format!(
435 "RmtInf/Ustrd total length must not exceed 140 characters; got {ustrd_total}"
436 ),
437 ));
438 }
439
440 for ustrd in &rmt_inf.ustrd {
442 if !is_sepa_charset(&ustrd.0) {
443 let bad: String = ustrd.0.chars().filter(|&c| !is_sepa_char(c)).collect();
444 errors.push(ValidationError::new(
445 "//Ustrd",
446 Severity::Error,
447 "SEPA_CHARSET",
448 format!(
449 "Field <Ustrd> contains characters outside the SEPA restricted \
450 Latin character set: {bad:?}"
451 ),
452 ));
453 }
454 }
455 }
456
457 let amt_str: &str = &tx.intr_bk_sttlm_amt.value.0;
459 Self::validate_sepa_amount(
460 amt_str,
461 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt",
462 &mut errors,
463 );
464
465 if let Some(nm) = &tx.dbtr.nm {
467 Self::check_sepa_name(&nm.0, &mut errors);
468 }
469 if let Some(nm) = &tx.cdtr.nm {
470 Self::check_sepa_name(&nm.0, &mut errors);
471 }
472
473 let has_dbtr_iban = tx.dbtr_acct.as_ref().is_some_and(|acct| {
475 acct.id.as_ref().is_some_and(|choice| {
476 matches!(choice.inner, AccountIdentification4Choice::IBAN(_))
477 })
478 });
479 let has_cdtr_iban = tx.cdtr_acct.as_ref().is_some_and(|acct| {
480 acct.id.as_ref().is_some_and(|choice| {
481 matches!(choice.inner, AccountIdentification4Choice::IBAN(_))
482 })
483 });
484 if !has_dbtr_iban && !has_cdtr_iban {
485 errors.push(ValidationError::new(
486 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf",
487 Severity::Error,
488 "SEPA_IBAN_REQUIRED",
489 "SEPA requires IBAN for both debtor and creditor accounts; none found",
490 ));
491 } else if !has_dbtr_iban || !has_cdtr_iban {
492 errors.push(ValidationError::new(
493 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf",
494 Severity::Warning,
495 "SEPA_IBAN_BOTH",
496 "SEPA requires IBAN for both debtor and creditor; only one found",
497 ));
498 }
499 }
500
501 ValidationResult::new(errors)
502 }
503}
504
505fn check_name(
508 xml: &str,
509 parent_tag: &str,
510 max_len: usize,
511 errors: &mut Vec<ValidationError>,
512 rule_id: &str,
513) {
514 let path = format!("/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/{parent_tag}");
515 super::common::check_name_in_parent(
516 xml,
517 parent_tag,
518 Some(max_len),
519 &path,
520 rule_id,
521 "SEPA",
522 errors,
523 true,
524 );
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn name_is_sepa() {
533 assert_eq!(SepaValidator::new().name(), "SEPA");
534 }
535
536 #[test]
537 fn supports_pacs008() {
538 let v = SepaValidator::new();
539 assert!(v.supported_messages().contains(&"pacs.008"));
540 }
541
542 #[test]
543 fn unsupported_message_returns_empty() {
544 let v = SepaValidator::new();
545 let result = v.validate("<xml/>", "pacs.009.001.10");
546 assert!(result.errors.is_empty());
547 }
548
549 #[test]
550 fn sepa_charset_ascii_allowed() {
551 assert!(is_sepa_charset("Alice Smith / 123"));
552 }
553
554 #[test]
555 fn sepa_charset_diacritics_allowed() {
556 assert!(is_sepa_charset("Müller")); }
558
559 #[test]
560 fn sepa_charset_control_chars_rejected() {
561 assert!(!is_sepa_charset("Alice\x01Smith"));
562 }
563
564 #[test]
565 fn sepa_charset_cyrillic_rejected() {
566 assert!(!is_sepa_charset("Алиса")); }
568
569 fn sepa_xml_with_amount(amount: &str) -> String {
571 format!(
572 r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.13">
573 <FIToFICstmrCdtTrf>
574 <GrpHdr><NbOfTxs>1</NbOfTxs><SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr>
575 <CdtTrfTxInf>
576 <IntrBkSttlmAmt Ccy="EUR">{amount}</IntrBkSttlmAmt>
577 <ChrgBr>SLEV</ChrgBr>
578 <Dbtr><Nm>Alice</Nm></Dbtr>
579 <Cdtr><Nm>Bob</Nm></Cdtr>
580 <DbtrAgt><FinInstnId><BICFI>BANKDEFF</BICFI></FinInstnId></DbtrAgt>
581 <CdtrAgt><FinInstnId><BICFI>BANKDEFF</BICFI></FinInstnId></CdtrAgt>
582 <DbtrAcct><Id><IBAN>DE89370400440532013000</IBAN></Id></DbtrAcct>
583 <CdtrAcct><Id><IBAN>DE89370400440532013000</IBAN></Id></CdtrAcct>
584 </CdtTrfTxInf>
585 </FIToFICstmrCdtTrf>
586</Document>"#
587 )
588 }
589
590 fn has_error(result: &ValidationResult, code: &str) -> bool {
591 result.errors.iter().any(|e| e.rule_id == code)
592 }
593
594 #[test]
595 fn sepa_amount_at_max_boundary() {
596 let v = SepaValidator::new();
597 let xml = sepa_xml_with_amount("999999999.99");
598 let result = v.validate(&xml, "pacs.008.001.13");
599 assert!(
600 !has_error(&result, "SEPA_AMOUNT_MAX"),
601 "999999999.99 should be within SEPA max; errors: {:?}",
602 result.errors
603 );
604 }
605
606 #[test]
607 fn sepa_amount_just_under_max() {
608 let v = SepaValidator::new();
609 let xml = sepa_xml_with_amount("999999999.98");
610 let result = v.validate(&xml, "pacs.008.001.13");
611 assert!(!has_error(&result, "SEPA_AMOUNT_MAX"));
612 }
613
614 #[test]
615 fn sepa_amount_exceeds_max() {
616 let v = SepaValidator::new();
617 let xml = sepa_xml_with_amount("1000000000.00");
618 let result = v.validate(&xml, "pacs.008.001.13");
619 assert!(
620 has_error(&result, "SEPA_AMOUNT_MAX"),
621 "1000000000.00 should exceed SEPA max"
622 );
623 }
624
625 #[test]
626 fn sepa_amount_at_min_boundary() {
627 let v = SepaValidator::new();
628 let xml = sepa_xml_with_amount("0.01");
629 let result = v.validate(&xml, "pacs.008.001.13");
630 assert!(
631 !has_error(&result, "SEPA_AMOUNT_MIN"),
632 "0.01 should be within SEPA min"
633 );
634 }
635
636 #[test]
637 fn sepa_amount_below_min() {
638 let v = SepaValidator::new();
639 let xml = sepa_xml_with_amount("0.00");
640 let result = v.validate(&xml, "pacs.008.001.13");
641 assert!(
642 has_error(&result, "SEPA_AMOUNT_MIN"),
643 "0.00 should be below SEPA min"
644 );
645 }
646}