use std::any::Any;
use std::sync::OnceLock;
use regex::Regex;
use super::xml_scan::{extract_attribute, extract_element, has_element, xml_byte_size};
use super::SchemeValidator;
use crate::error::{Severity, ValidationError, ValidationResult};
pub struct FedNowValidator {
max_amount_cents: u64,
}
impl FedNowValidator {
pub fn new() -> Self {
Self {
max_amount_cents: 50_000_000,
}
}
pub fn with_max_amount(max_amount: f64) -> Self {
assert!(
max_amount > 0.0 && max_amount.is_finite(),
"max_amount must be positive and finite"
);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Self {
max_amount_cents: (max_amount * 100.0).round() as u64,
}
}
}
impl Default for FedNowValidator {
fn default() -> Self {
Self::new()
}
}
fn uetr_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
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}$")
.expect("valid regex")
})
}
fn is_valid_uetr(value: &str) -> bool {
uetr_re().is_match(value)
}
impl SchemeValidator for FedNowValidator {
fn name(&self) -> &'static str {
"FedNow"
}
fn supported_messages(&self) -> &[&str] {
&[
"pacs.008", "pacs.002", "pacs.004", "pacs.028", "camt.056", "pain.013",
]
}
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();
let size = xml_byte_size(xml);
let size_limit: usize = if short_type == "pacs.028" {
32 * 1024
} else {
64 * 1024
};
if size > size_limit {
errors.push(ValidationError::new(
"/Document",
Severity::Error,
"FEDNOW_MSG_SIZE",
format!(
"Message size {size} bytes exceeds FedNow limit of {size_limit} bytes for {short_type}"
),
));
}
if short_type != "pacs.008" {
return ValidationResult::new(errors);
}
if let Some(nb) = extract_element(xml, "NbOfTxs") {
if nb != "1" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs",
Severity::Error,
"FEDNOW_SINGLE_TX",
format!(
"FedNow requires exactly one transaction per group (NbOfTxs = \"1\"), got \"{nb}\""
),
));
}
}
if let Some(sttlm_mtd) = extract_element(xml, "SttlmMtd") {
if sttlm_mtd != "CLRG" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd",
Severity::Error,
"FEDNOW_STTLM_MTD",
format!("FedNow requires SttlmMtd = \"CLRG\", got \"{sttlm_mtd}\""),
));
}
}
if let Some(chrg_br) = extract_element(xml, "ChrgBr") {
if chrg_br != "SLEV" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
Severity::Error,
"FEDNOW_CHRGBR",
format!("FedNow requires ChrgBr = \"SLEV\", got \"{chrg_br}\""),
));
}
}
if let Some(ccy) = extract_attribute(xml, "IntrBkSttlmAmt", "Ccy") {
if ccy != "USD" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
Severity::Error,
"FEDNOW_CURRENCY",
format!("FedNow only accepts USD transactions; found currency \"{ccy}\""),
));
}
}
if let Some(amt_str) = extract_element(xml, "IntrBkSttlmAmt") {
self.validate_amount(
amt_str,
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt",
&mut errors,
);
}
if let Some(uetr) = extract_element(xml, "UETR") {
if !is_valid_uetr(uetr) {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
Severity::Error,
"FEDNOW_UETR_FORMAT",
format!("UETR must be a valid UUID v4; got \"{uetr}\""),
));
}
} else {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
Severity::Error,
"FEDNOW_UETR_REQUIRED",
"FedNow requires a UETR (UUID v4) in PmtId",
));
}
if let Some(e2e) = extract_element(xml, "EndToEndId") {
if e2e.chars().count() > 35 {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
Severity::Error,
"FEDNOW_E2E_LENGTH",
format!(
"EndToEndId must be at most 35 characters; got {} characters",
e2e.chars().count()
),
));
}
} else {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
Severity::Error,
"FEDNOW_E2E_REQUIRED",
"FedNow requires an EndToEndId in PmtId",
));
}
check_name_length(xml, "Dbtr", &mut errors, "FEDNOW_DBTR_NM_LENGTH");
check_name_length(xml, "Cdtr", &mut errors, "FEDNOW_CDTR_NM_LENGTH");
for ustrd in super::xml_scan::extract_all_elements(xml, "Ustrd") {
if ustrd.chars().count() > 140 {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd",
Severity::Error,
"FEDNOW_USTRD_LENGTH",
format!(
"Ustrd element must be at most 140 characters; got {} characters",
ustrd.chars().count()
),
));
}
}
if !has_element(xml, "AppHdr") && !has_element(xml, "BizMsgIdr") {
errors.push(ValidationError::new(
"/AppHdr",
Severity::Warning,
"FEDNOW_APPHDR_MISSING",
"Business Application Header (AppHdr) is recommended for FedNow messages",
));
}
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 FedNowValidator {
fn validate_amount(&self, amt_str: &str, path: &str, errors: &mut Vec<ValidationError>) {
let decimal_ok = amt_str
.find('.')
.is_some_and(|dot| amt_str.len() - dot - 1 == 2);
if !decimal_ok {
errors.push(ValidationError::new(
path,
Severity::Error,
"FEDNOW_AMOUNT_DECIMALS",
format!("FedNow amounts must have exactly 2 decimal places; got \"{amt_str}\""),
));
}
match parse_amount_cents(amt_str) {
Some(cents) => {
if cents < 1 {
errors.push(ValidationError::new(
path,
Severity::Error,
"FEDNOW_AMOUNT_MIN",
format!("FedNow minimum amount is 0.01 USD; got \"{amt_str}\""),
));
}
if cents > self.max_amount_cents {
errors.push(ValidationError::new(
path,
Severity::Error,
"FEDNOW_AMOUNT_LIMIT",
format!(
"FedNow maximum amount is {}.{:02} USD; got \"{amt_str}\"",
self.max_amount_cents / 100,
self.max_amount_cents % 100
),
));
}
}
None => {
errors.push(ValidationError::new(
path,
Severity::Error,
"FEDNOW_AMOUNT_FORMAT",
format!("Cannot parse amount as a number: \"{amt_str}\""),
));
}
}
}
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::{
ChargeBearerType1Code, SettlementMethod1Code,
};
let mut errors: Vec<ValidationError> = Vec::new();
let msg = &doc.fi_to_fi_cstmr_cdt_trf;
if msg.grp_hdr.nb_of_txs.0 != "1" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs",
Severity::Error,
"FEDNOW_SINGLE_TX",
format!(
"FedNow requires exactly one transaction per group (NbOfTxs = \"1\"), got \"{}\"",
msg.grp_hdr.nb_of_txs.0
),
));
}
if msg.grp_hdr.sttlm_inf.sttlm_mtd != SettlementMethod1Code::Clrg {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd",
Severity::Error,
"FEDNOW_STTLM_MTD",
format!(
"FedNow requires SttlmMtd = \"CLRG\", got {:?}",
msg.grp_hdr.sttlm_inf.sttlm_mtd
),
));
}
for tx in &msg.cdt_trf_tx_inf {
if tx.chrg_br != ChargeBearerType1Code::Slev {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
Severity::Error,
"FEDNOW_CHRGBR",
format!("FedNow requires ChrgBr = \"SLEV\", got {:?}", tx.chrg_br),
));
}
let ccy = &tx.intr_bk_sttlm_amt.ccy.0;
if ccy != "USD" {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
Severity::Error,
"FEDNOW_CURRENCY",
format!("FedNow only accepts USD transactions; found currency \"{ccy}\""),
));
}
let amt_str = &tx.intr_bk_sttlm_amt.value.0;
self.validate_amount(
amt_str,
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt",
&mut errors,
);
match &tx.pmt_id.uetr {
Some(uetr) if !is_valid_uetr(&uetr.0) => {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
Severity::Error,
"FEDNOW_UETR_FORMAT",
format!("UETR must be a valid UUID v4; got \"{}\"", uetr.0),
));
}
None => {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
Severity::Error,
"FEDNOW_UETR_REQUIRED",
"FedNow requires a UETR (UUID v4) in PmtId",
));
}
Some(_) => {} }
if tx.pmt_id.end_to_end_id.0.trim().is_empty() {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
Severity::Error,
"FEDNOW_E2E_REQUIRED",
"FedNow requires a non-empty EndToEndId in PmtId",
));
}
if let Some(nm) = &tx.dbtr.nm {
if nm.0.chars().count() > 140 {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm",
Severity::Error,
"FEDNOW_DBTR_NM_LENGTH",
format!(
"Dbtr/Nm must be at most 140 characters; got {} characters",
nm.0.chars().count()
),
));
}
}
if let Some(nm) = &tx.cdtr.nm {
if nm.0.chars().count() > 140 {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm",
Severity::Error,
"FEDNOW_CDTR_NM_LENGTH",
format!(
"Cdtr/Nm must be at most 140 characters; got {} characters",
nm.0.chars().count()
),
));
}
}
if let Some(rmt_inf) = &tx.rmt_inf {
for ustrd in &rmt_inf.ustrd {
if ustrd.0.chars().count() > 140 {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd",
Severity::Error,
"FEDNOW_USTRD_LENGTH",
format!(
"Ustrd element must be at most 140 characters; got {} characters",
ustrd.0.chars().count()
),
));
}
}
}
}
ValidationResult::new(errors)
}
}
use super::common::parse_amount_cents;
fn check_name_length(
xml: &str,
parent_tag: &str,
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(140),
&path,
rule_id,
"FedNow",
errors,
false, );
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_is_fednow() {
assert_eq!(FedNowValidator::new().name(), "FedNow");
}
#[test]
fn supports_pacs008() {
let v = FedNowValidator::new();
assert!(v.supported_messages().contains(&"pacs.008"));
}
#[test]
fn unsupported_message_returns_empty() {
let v = FedNowValidator::new();
let result = v.validate("<xml/>", "pacs.009.001.10");
assert!(result.errors.is_empty());
}
#[test]
fn valid_uetr_accepted() {
assert!(is_valid_uetr("97ed4827-7b6f-4491-a06f-b548d5a7512d"));
}
#[test]
fn invalid_uetr_rejected() {
assert!(!is_valid_uetr("not-a-uuid"));
assert!(!is_valid_uetr("97ed4827-7b6f-3491-a06f-b548d5a7512d")); }
#[test]
fn default_max_amount_is_500k() {
let v = FedNowValidator::default();
assert_eq!(v.max_amount_cents, 50_000_000);
}
#[test]
fn custom_max_amount() {
let v = FedNowValidator::with_max_amount(25_000_000.0);
assert_eq!(v.max_amount_cents, 2_500_000_000);
}
}