Skip to main content

mx20022_validate/schemes/
fednow.rs

1//! `FedNow` payment scheme validator.
2//!
3//! The Federal Reserve's real-time gross settlement service imposes
4//! additional constraints on top of the base ISO 20022 schema:
5//!
6//! - Only USD transactions are accepted.
7//! - Settlement method must be `CLRG`.
8//! - Charges bearer must be `SLEV`.
9//! - A single transaction per group (`NbOfTxs = "1"`).
10//! - UETR is mandatory (UUID v4 format).
11//! - End-to-end ID is mandatory (≤ 35 characters).
12//! - Amount in `[0.01, 500_000.00]` USD (the upper bound is configurable up to
13//!   25,000,000.00 USD for high-value participants).
14//! - Message size limits: 64 KB for pacs.008 / pacs.004, 32 KB for pacs.028.
15
16use std::any::Any;
17use std::sync::OnceLock;
18
19use regex::Regex;
20
21use super::xml_scan::{extract_attribute, extract_element, has_element, xml_byte_size};
22use super::SchemeValidator;
23use crate::error::{Severity, ValidationError, ValidationResult};
24
25/// `FedNow` scheme validator.
26///
27/// # Examples
28///
29/// ```
30/// use mx20022_validate::schemes::fednow::FedNowValidator;
31/// use mx20022_validate::schemes::SchemeValidator;
32///
33/// let validator = FedNowValidator::new();
34/// assert_eq!(validator.name(), "FedNow");
35/// assert!(validator.supported_messages().contains(&"pacs.008"));
36/// ```
37pub struct FedNowValidator {
38    /// Maximum permitted settlement amount in USD cents.
39    max_amount_cents: u64,
40}
41
42impl FedNowValidator {
43    /// Create a validator with the standard 500,000 USD limit.
44    pub fn new() -> Self {
45        Self {
46            max_amount_cents: 50_000_000,
47        }
48    }
49
50    /// Create a validator with a custom maximum amount (e.g. 25,000,000 USD for
51    /// high-value participants).
52    ///
53    /// # Panics
54    ///
55    /// Panics if `max_amount` is not positive or not finite.
56    pub fn with_max_amount(max_amount: f64) -> Self {
57        assert!(
58            max_amount > 0.0 && max_amount.is_finite(),
59            "max_amount must be positive and finite"
60        );
61        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
62        Self {
63            max_amount_cents: (max_amount * 100.0).round() as u64,
64        }
65    }
66}
67
68impl Default for FedNowValidator {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74/// Compiled UUID v4 regex, cached for the lifetime of the process.
75fn uetr_re() -> &'static Regex {
76    static RE: OnceLock<Regex> = OnceLock::new();
77    RE.get_or_init(|| {
78        Regex::new(r"(?i)^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
79            .expect("valid regex")
80    })
81}
82
83/// UUID v4 pattern (8-4-4-4-12 hex groups).
84fn is_valid_uetr(value: &str) -> bool {
85    uetr_re().is_match(value)
86}
87
88impl SchemeValidator for FedNowValidator {
89    fn name(&self) -> &'static str {
90        "FedNow"
91    }
92
93    fn supported_messages(&self) -> &[&str] {
94        &[
95            "pacs.008", "pacs.002", "pacs.004", "pacs.028", "camt.056", "pain.013",
96        ]
97    }
98
99    fn validate(&self, xml: &str, message_type: &str) -> ValidationResult {
100        let short_type = super::short_message_type(message_type);
101
102        if !self.supported_messages().contains(&short_type.as_str()) {
103            return ValidationResult::default();
104        }
105
106        let mut errors: Vec<ValidationError> = Vec::new();
107
108        // --- Message size ---------------------------------------------------
109        let size = xml_byte_size(xml);
110        let size_limit: usize = if short_type == "pacs.028" {
111            32 * 1024
112        } else {
113            64 * 1024
114        };
115        if size > size_limit {
116            errors.push(ValidationError::new(
117                "/Document",
118                Severity::Error,
119                "FEDNOW_MSG_SIZE",
120                format!(
121                    "Message size {size} bytes exceeds FedNow limit of {size_limit} bytes for {short_type}"
122                ),
123            ));
124        }
125
126        // The remaining checks are pacs.008-specific field rules.
127        if short_type != "pacs.008" {
128            return ValidationResult::new(errors);
129        }
130
131        // --- NbOfTxs must be "1" -------------------------------------------
132        if let Some(nb) = extract_element(xml, "NbOfTxs") {
133            if nb != "1" {
134                errors.push(ValidationError::new(
135                    "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs",
136                    Severity::Error,
137                    "FEDNOW_SINGLE_TX",
138                    format!(
139                        "FedNow requires exactly one transaction per group (NbOfTxs = \"1\"), got \"{nb}\""
140                    ),
141                ));
142            }
143        }
144
145        // --- Settlement method must be CLRG ---------------------------------
146        if let Some(sttlm_mtd) = extract_element(xml, "SttlmMtd") {
147            if sttlm_mtd != "CLRG" {
148                errors.push(ValidationError::new(
149                    "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd",
150                    Severity::Error,
151                    "FEDNOW_STTLM_MTD",
152                    format!("FedNow requires SttlmMtd = \"CLRG\", got \"{sttlm_mtd}\""),
153                ));
154            }
155        }
156
157        // --- ChrgBr must be SLEV --------------------------------------------
158        if let Some(chrg_br) = extract_element(xml, "ChrgBr") {
159            if chrg_br != "SLEV" {
160                errors.push(ValidationError::new(
161                    "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
162                    Severity::Error,
163                    "FEDNOW_CHRGBR",
164                    format!("FedNow requires ChrgBr = \"SLEV\", got \"{chrg_br}\""),
165                ));
166            }
167        }
168
169        // --- Currency must be USD -------------------------------------------
170        if let Some(ccy) = extract_attribute(xml, "IntrBkSttlmAmt", "Ccy") {
171            if ccy != "USD" {
172                errors.push(ValidationError::new(
173                    "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
174                    Severity::Error,
175                    "FEDNOW_CURRENCY",
176                    format!("FedNow only accepts USD transactions; found currency \"{ccy}\""),
177                ));
178            }
179        }
180
181        // --- Amount range ---------------------------------------------------
182        if let Some(amt_str) = extract_element(xml, "IntrBkSttlmAmt") {
183            self.validate_amount(
184                amt_str,
185                "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt",
186                &mut errors,
187            );
188        }
189
190        // --- UETR is required and must be UUID v4 ---------------------------
191        if let Some(uetr) = extract_element(xml, "UETR") {
192            if !is_valid_uetr(uetr) {
193                errors.push(ValidationError::new(
194                    "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
195                    Severity::Error,
196                    "FEDNOW_UETR_FORMAT",
197                    format!("UETR must be a valid UUID v4; got \"{uetr}\""),
198                ));
199            }
200        } else {
201            errors.push(ValidationError::new(
202                "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
203                Severity::Error,
204                "FEDNOW_UETR_REQUIRED",
205                "FedNow requires a UETR (UUID v4) in PmtId",
206            ));
207        }
208
209        // --- End-to-end ID is required and max 35 chars ---------------------
210        if let Some(e2e) = extract_element(xml, "EndToEndId") {
211            if e2e.chars().count() > 35 {
212                errors.push(ValidationError::new(
213                    "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
214                    Severity::Error,
215                    "FEDNOW_E2E_LENGTH",
216                    format!(
217                        "EndToEndId must be at most 35 characters; got {} characters",
218                        e2e.chars().count()
219                    ),
220                ));
221            }
222        } else {
223            errors.push(ValidationError::new(
224                "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
225                Severity::Error,
226                "FEDNOW_E2E_REQUIRED",
227                "FedNow requires an EndToEndId in PmtId",
228            ));
229        }
230
231        // --- Debtor name max 140 chars --------------------------------------
232        // We check the first <Nm> inside <Dbtr>. A simple heuristic: scan for
233        // the Dbtr block and extract the Nm within it.
234        check_name_length(xml, "Dbtr", &mut errors, "FEDNOW_DBTR_NM_LENGTH");
235        check_name_length(xml, "Cdtr", &mut errors, "FEDNOW_CDTR_NM_LENGTH");
236
237        // --- RmtInf/Ustrd max 140 chars per element -------------------------
238        for ustrd in super::xml_scan::extract_all_elements(xml, "Ustrd") {
239            if ustrd.chars().count() > 140 {
240                errors.push(ValidationError::new(
241                    "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd",
242                    Severity::Error,
243                    "FEDNOW_USTRD_LENGTH",
244                    format!(
245                        "Ustrd element must be at most 140 characters; got {} characters",
246                        ustrd.chars().count()
247                    ),
248                ));
249            }
250        }
251
252        // --- AppHdr presence is a soft check (not required but common) ------
253        if !has_element(xml, "AppHdr") && !has_element(xml, "BizMsgIdr") {
254            errors.push(ValidationError::new(
255                "/AppHdr",
256                Severity::Warning,
257                "FEDNOW_APPHDR_MISSING",
258                "Business Application Header (AppHdr) is recommended for FedNow messages",
259            ));
260        }
261
262        ValidationResult::new(errors)
263    }
264
265    fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option<ValidationResult> {
266        use mx20022_model::generated::pacs::pacs_008_001_13;
267
268        let short_type = super::short_message_type(message_type);
269        if !self.supported_messages().contains(&short_type.as_str()) {
270            return None;
271        }
272
273        // Only pacs.008 has typed field-level checks.
274        if short_type != "pacs.008" {
275            return None;
276        }
277
278        let doc = msg.downcast_ref::<pacs_008_001_13::Document>()?;
279
280        Some(self.validate_pacs008_typed(doc))
281    }
282}
283
284impl FedNowValidator {
285    fn validate_amount(&self, amt_str: &str, path: &str, errors: &mut Vec<ValidationError>) {
286        let decimal_ok = amt_str
287            .find('.')
288            .is_some_and(|dot| amt_str.len() - dot - 1 == 2);
289        if !decimal_ok {
290            errors.push(ValidationError::new(
291                path,
292                Severity::Error,
293                "FEDNOW_AMOUNT_DECIMALS",
294                format!("FedNow amounts must have exactly 2 decimal places; got \"{amt_str}\""),
295            ));
296        }
297        match parse_amount_cents(amt_str) {
298            Some(cents) => {
299                if cents < 1 {
300                    errors.push(ValidationError::new(
301                        path,
302                        Severity::Error,
303                        "FEDNOW_AMOUNT_MIN",
304                        format!("FedNow minimum amount is 0.01 USD; got \"{amt_str}\""),
305                    ));
306                }
307                if cents > self.max_amount_cents {
308                    errors.push(ValidationError::new(
309                        path,
310                        Severity::Error,
311                        "FEDNOW_AMOUNT_LIMIT",
312                        format!(
313                            "FedNow maximum amount is {}.{:02} USD; got \"{amt_str}\"",
314                            self.max_amount_cents / 100,
315                            self.max_amount_cents % 100
316                        ),
317                    ));
318                }
319            }
320            None => {
321                errors.push(ValidationError::new(
322                    path,
323                    Severity::Error,
324                    "FEDNOW_AMOUNT_FORMAT",
325                    format!("Cannot parse amount as a number: \"{amt_str}\""),
326                ));
327            }
328        }
329    }
330
331    /// Typed validation for pacs.008 messages.
332    fn validate_pacs008_typed(
333        &self,
334        doc: &mx20022_model::generated::pacs::pacs_008_001_13::Document,
335    ) -> ValidationResult {
336        use mx20022_model::generated::pacs::pacs_008_001_13::{
337            ChargeBearerType1Code, SettlementMethod1Code,
338        };
339
340        let mut errors: Vec<ValidationError> = Vec::new();
341        let msg = &doc.fi_to_fi_cstmr_cdt_trf;
342
343        // --- NbOfTxs must be "1" -------------------------------------------
344        if msg.grp_hdr.nb_of_txs.0 != "1" {
345            errors.push(ValidationError::new(
346                "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs",
347                Severity::Error,
348                "FEDNOW_SINGLE_TX",
349                format!(
350                    "FedNow requires exactly one transaction per group (NbOfTxs = \"1\"), got \"{}\"",
351                    msg.grp_hdr.nb_of_txs.0
352                ),
353            ));
354        }
355
356        // --- Settlement method must be CLRG ---------------------------------
357        if msg.grp_hdr.sttlm_inf.sttlm_mtd != SettlementMethod1Code::Clrg {
358            errors.push(ValidationError::new(
359                "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd",
360                Severity::Error,
361                "FEDNOW_STTLM_MTD",
362                format!(
363                    "FedNow requires SttlmMtd = \"CLRG\", got {:?}",
364                    msg.grp_hdr.sttlm_inf.sttlm_mtd
365                ),
366            ));
367        }
368
369        // Validate each credit transfer transaction.
370        for tx in &msg.cdt_trf_tx_inf {
371            // --- ChrgBr must be SLEV ----------------------------------------
372            if tx.chrg_br != ChargeBearerType1Code::Slev {
373                errors.push(ValidationError::new(
374                    "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
375                    Severity::Error,
376                    "FEDNOW_CHRGBR",
377                    format!("FedNow requires ChrgBr = \"SLEV\", got {:?}", tx.chrg_br),
378                ));
379            }
380
381            // --- Currency must be USD ---------------------------------------
382            let ccy = &tx.intr_bk_sttlm_amt.ccy.0;
383            if ccy != "USD" {
384                errors.push(ValidationError::new(
385                    "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
386                    Severity::Error,
387                    "FEDNOW_CURRENCY",
388                    format!("FedNow only accepts USD transactions; found currency \"{ccy}\""),
389                ));
390            }
391
392            // --- Amount range -----------------------------------------------
393            let amt_str = &tx.intr_bk_sttlm_amt.value.0;
394            self.validate_amount(
395                amt_str,
396                "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt",
397                &mut errors,
398            );
399
400            // --- UETR is required and must be UUID v4 -----------------------
401            match &tx.pmt_id.uetr {
402                Some(uetr) if !is_valid_uetr(&uetr.0) => {
403                    errors.push(ValidationError::new(
404                        "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
405                        Severity::Error,
406                        "FEDNOW_UETR_FORMAT",
407                        format!("UETR must be a valid UUID v4; got \"{}\"", uetr.0),
408                    ));
409                }
410                None => {
411                    errors.push(ValidationError::new(
412                        "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
413                        Severity::Error,
414                        "FEDNOW_UETR_REQUIRED",
415                        "FedNow requires a UETR (UUID v4) in PmtId",
416                    ));
417                }
418                Some(_) => {} // Valid UETR
419            }
420
421            // --- End-to-end ID is required (length covered by XSD) ----------
422            // Max35Text is the type — XSD validation handles the 35-char limit.
423            // We only need to check it's not empty/whitespace for FedNow.
424            if tx.pmt_id.end_to_end_id.0.trim().is_empty() {
425                errors.push(ValidationError::new(
426                    "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
427                    Severity::Error,
428                    "FEDNOW_E2E_REQUIRED",
429                    "FedNow requires a non-empty EndToEndId in PmtId",
430                ));
431            }
432
433            // --- Debtor name max 140 chars ----------------------------------
434            if let Some(nm) = &tx.dbtr.nm {
435                if nm.0.chars().count() > 140 {
436                    errors.push(ValidationError::new(
437                        "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm",
438                        Severity::Error,
439                        "FEDNOW_DBTR_NM_LENGTH",
440                        format!(
441                            "Dbtr/Nm must be at most 140 characters; got {} characters",
442                            nm.0.chars().count()
443                        ),
444                    ));
445                }
446            }
447
448            // --- Creditor name max 140 chars --------------------------------
449            if let Some(nm) = &tx.cdtr.nm {
450                if nm.0.chars().count() > 140 {
451                    errors.push(ValidationError::new(
452                        "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm",
453                        Severity::Error,
454                        "FEDNOW_CDTR_NM_LENGTH",
455                        format!(
456                            "Cdtr/Nm must be at most 140 characters; got {} characters",
457                            nm.0.chars().count()
458                        ),
459                    ));
460                }
461            }
462
463            // --- RmtInf/Ustrd max 140 chars per element ---------------------
464            if let Some(rmt_inf) = &tx.rmt_inf {
465                for ustrd in &rmt_inf.ustrd {
466                    if ustrd.0.chars().count() > 140 {
467                        errors.push(ValidationError::new(
468                            "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd",
469                            Severity::Error,
470                            "FEDNOW_USTRD_LENGTH",
471                            format!(
472                                "Ustrd element must be at most 140 characters; got {} characters",
473                                ustrd.0.chars().count()
474                            ),
475                        ));
476                    }
477                }
478            }
479        }
480
481        // Note: AppHdr check and message size check require raw XML and are
482        // not covered by the typed path. Those remain in the XML-based
483        // `validate` method.
484
485        ValidationResult::new(errors)
486    }
487}
488
489// Amount parsing functions are defined in `super::common`.
490use super::common::parse_amount_cents;
491
492/// Check that the `<Nm>` child inside `<parent_tag>` does not exceed 140 chars.
493fn check_name_length(
494    xml: &str,
495    parent_tag: &str,
496    errors: &mut Vec<ValidationError>,
497    rule_id: &str,
498) {
499    let path = format!("/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/{parent_tag}");
500    super::common::check_name_in_parent(
501        xml,
502        parent_tag,
503        Some(140),
504        &path,
505        rule_id,
506        "FedNow",
507        errors,
508        false, // FedNow doesn't require names, only limits length
509    );
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn name_is_fednow() {
518        assert_eq!(FedNowValidator::new().name(), "FedNow");
519    }
520
521    #[test]
522    fn supports_pacs008() {
523        let v = FedNowValidator::new();
524        assert!(v.supported_messages().contains(&"pacs.008"));
525    }
526
527    #[test]
528    fn unsupported_message_returns_empty() {
529        let v = FedNowValidator::new();
530        let result = v.validate("<xml/>", "pacs.009.001.10");
531        assert!(result.errors.is_empty());
532    }
533
534    #[test]
535    fn valid_uetr_accepted() {
536        assert!(is_valid_uetr("97ed4827-7b6f-4491-a06f-b548d5a7512d"));
537    }
538
539    #[test]
540    fn invalid_uetr_rejected() {
541        assert!(!is_valid_uetr("not-a-uuid"));
542        assert!(!is_valid_uetr("97ed4827-7b6f-3491-a06f-b548d5a7512d")); // version 3, not 4
543    }
544
545    #[test]
546    fn default_max_amount_is_500k() {
547        let v = FedNowValidator::default();
548        assert_eq!(v.max_amount_cents, 50_000_000);
549    }
550
551    #[test]
552    fn custom_max_amount() {
553        let v = FedNowValidator::with_max_amount(25_000_000.0);
554        assert_eq!(v.max_amount_cents, 2_500_000_000);
555    }
556
557    #[test]
558    fn parse_amount_cents_normal() {
559        assert_eq!(parse_amount_cents("100.50"), Some(10050));
560    }
561
562    #[test]
563    fn parse_amount_cents_minimum() {
564        assert_eq!(parse_amount_cents("0.01"), Some(1));
565    }
566
567    #[test]
568    fn parse_amount_cents_large() {
569        assert_eq!(parse_amount_cents("999999.99"), Some(99999999));
570    }
571
572    #[test]
573    fn parse_amount_cents_no_dot() {
574        assert_eq!(parse_amount_cents("100"), None);
575    }
576
577    #[test]
578    fn parse_amount_cents_bad_integer() {
579        assert_eq!(parse_amount_cents("abc.50"), None);
580    }
581
582    #[test]
583    fn parse_amount_cents_bad_fraction() {
584        assert_eq!(parse_amount_cents("100.ab"), None);
585    }
586}