Skip to main content

mx20022_validate/schemes/
sepa.rs

1//! SEPA (Single Euro Payments Area) scheme validator.
2//!
3//! Enforces European Payments Council (EPC) usage guidelines for the SEPA
4//! Credit Transfer (SCT) scheme:
5//!
6//! - Only EUR transactions.
7//! - Settlement method must be `CLRG`; charges bearer must be `SLEV`.
8//! - Single transaction per group (`NbOfTxs = "1"`).
9//! - Debtor and creditor names are required (≤ 70 characters each).
10//! - End-to-end ID ≤ 35 characters.
11//! - `RmtInf/Ustrd` ≤ 140 characters in total.
12//! - Amount in `[0.01, 999_999_999.99]` EUR with at most 2 decimal places.
13//! - IBAN is required for both debtor and creditor accounts.
14//! - SEPA restricted Latin character set on name and address fields.
15
16use 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
22/// SEPA Credit Transfer scheme validator.
23///
24/// # Examples
25///
26/// ```
27/// use mx20022_validate::schemes::sepa::SepaValidator;
28/// use mx20022_validate::schemes::SchemeValidator;
29///
30/// let validator = SepaValidator::new();
31/// assert_eq!(validator.name(), "SEPA");
32/// assert!(validator.supported_messages().contains(&"pacs.008"));
33/// ```
34pub struct SepaValidator;
35
36impl SepaValidator {
37    /// Create a new `SepaValidator`.
38    pub fn new() -> Self {
39        Self
40    }
41}
42
43impl Default for SepaValidator {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49/// Returns `true` if `c` is in the SEPA restricted Latin character set.
50fn 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
59/// Check whether a string contains only characters from the SEPA restricted
60/// Latin character set.
61///
62/// Allowed: a-z A-Z 0-9 / - ? : ( ) . , ' + Space and Latin Extended-A
63/// characters with diacritics (U+00C0 – U+00FF, i.e. À–ÿ).
64pub fn is_sepa_charset(s: &str) -> bool {
65    s.chars().all(is_sepa_char)
66}
67
68/// Tags whose text content must conform to the SEPA character set.
69const 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        // The field-level checks are pacs.008-specific.
90        if short_type != "pacs.008" {
91            return ValidationResult::new(errors);
92        }
93
94        // --- Currency must be EUR -------------------------------------------
95        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        // --- ChrgBr must be SLEV -------------------------------------------
107        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        // --- Settlement method must be CLRG ---------------------------------
126        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        // --- NbOfTxs must be "1" -------------------------------------------
138        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        // --- Debtor name required, max 70 chars -----------------------------
152        check_name(xml, "Dbtr", 70, &mut errors, "SEPA_DBTR_NM");
153        // --- Creditor name required, max 70 chars ---------------------------
154        check_name(xml, "Cdtr", 70, &mut errors, "SEPA_CDTR_NM");
155
156        // --- End-to-end ID max 35 chars -------------------------------------
157        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        // --- Ustrd total length max 140 chars --------------------------------
172        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        // --- Amount range ---------------------------------------------------
188        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        // --- IBAN required for debtor and creditor --------------------------
197        // Presence check only — format is validated elsewhere.
198        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        // --- SEPA character set check ---------------------------------------
216        for tag in CHARSET_TAGS {
217            for value in extract_all_elements(xml, tag) {
218                if !is_sepa_charset(value) {
219                    // Report just the offending characters.
220                    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    /// Validate a SEPA amount string: decimal places, min, max.
257    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    /// Check that a name string conforms to the SEPA restricted Latin character set.
298    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    /// Typed validation for pacs.008 messages under SEPA SCT rules.
314    #[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        // --- Settlement method must be CLRG ---------------------------------
327        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        // --- NbOfTxs must be "1" -------------------------------------------
340        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            // --- Currency must be EUR ---------------------------------------
354            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            // --- ChrgBr must be SLEV ----------------------------------------
365            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            // --- Debtor name required, max 70 chars -------------------------
375            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            // --- Creditor name required, max 70 chars -----------------------
399            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            // --- End-to-end ID max 35 chars ---------------------------------
423            // Max35Text enforces 35 via XSD. Typed path trusts XSD validation
424            // for length but checks that it's present (it's a required field).
425
426            // --- Ustrd total length max 140 chars ---------------------------
427            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                // SEPA character set check on Ustrd.
441                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            // --- Amount range -----------------------------------------------
458            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            // --- SEPA character set check on names --------------------------
466            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            // --- IBAN required for debtor and creditor accounts ---
474            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
505/// Validate that the `<Nm>` child of `<parent_tag>` is present and within
506/// `max_len` characters.
507fn 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")); // ü is U+00FC, in range
557    }
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("Алиса")); // Cyrillic
567    }
568
569    /// Build a minimal pacs.008 XML with the given amount for SEPA validation.
570    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}