1use 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
25pub struct FedNowValidator {
38 max_amount_cents: u64,
40}
41
42impl FedNowValidator {
43 pub fn new() -> Self {
45 Self {
46 max_amount_cents: 50_000_000,
47 }
48 }
49
50 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
74fn 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
83fn 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 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 if short_type != "pacs.008" {
128 return ValidationResult::new(errors);
129 }
130
131 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 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 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 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 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 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 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 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 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 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 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 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 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 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 for tx in &msg.cdt_trf_tx_inf {
371 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 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 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 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(_) => {} }
420
421 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 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 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 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 ValidationResult::new(errors)
486 }
487}
488
489use super::common::parse_amount_cents;
491
492fn 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, );
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")); }
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}