use std::any::Any;
use super::xml_scan::{extract_all_elements, extract_element, has_element};
use super::SchemeValidator;
use crate::error::{Severity, ValidationError, ValidationResult};
pub struct CbprPlusValidator;
impl CbprPlusValidator {
pub fn new() -> Self {
Self
}
}
impl Default for CbprPlusValidator {
fn default() -> Self {
Self::new()
}
}
const VALID_CHRGBR: &[&str] = &["CRED", "DEBT", "SHAR", "SLEV"];
impl SchemeValidator for CbprPlusValidator {
fn name(&self) -> &'static str {
"CBPR+"
}
fn supported_messages(&self) -> &[&str] {
&[
"pacs.008", "pacs.009", "pacs.002", "pacs.004", "camt.056", "camt.029",
]
}
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();
check_control_characters(xml, &mut errors);
if !has_element(xml, "AppHdr") && !has_element(xml, "BizMsgIdr") {
errors.push(ValidationError::new(
"/AppHdr",
Severity::Error,
"CBPR_BAH_REQUIRED",
"CBPR+ requires a Business Application Header (AppHdr / BizMsgIdr)",
));
}
if short_type != "pacs.008" {
return ValidationResult::new(errors);
}
let bic_fields: &[(&str, &str, &str)] = &[
(
"InstgAgt",
"CBPR_INSTG_AGT_BIC",
"/Document/FIToFICstmrCdtTrf/GrpHdr/InstgAgt/FinInstnId/BICFI",
),
(
"InstdAgt",
"CBPR_INSTD_AGT_BIC",
"/Document/FIToFICstmrCdtTrf/GrpHdr/InstdAgt/FinInstnId/BICFI",
),
(
"DbtrAgt",
"CBPR_DBTR_AGT_BIC",
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/DbtrAgt/FinInstnId/BICFI",
),
(
"CdtrAgt",
"CBPR_CDTR_AGT_BIC",
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/CdtrAgt/FinInstnId/BICFI",
),
];
for (parent, rule_id, path) in bic_fields {
check_bic_in_parent(xml, parent, path, rule_id, &mut errors);
}
check_name_required(xml, "Dbtr", "CBPR_DBTR_NM_REQUIRED", &mut errors);
check_name_required(xml, "Cdtr", "CBPR_CDTR_NM_REQUIRED", &mut errors);
if !has_element(xml, "UETR") {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
Severity::Error,
"CBPR_UETR_REQUIRED",
"CBPR+ requires a UETR in PmtId",
));
}
if !has_element(xml, "EndToEndId") {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
Severity::Error,
"CBPR_E2E_REQUIRED",
"CBPR+ requires an EndToEndId in PmtId",
));
}
if let Some(chrg_br) = extract_element(xml, "ChrgBr") {
if !VALID_CHRGBR.contains(&chrg_br) {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
Severity::Error,
"CBPR_CHRGBR_VALUE",
format!("ChrgBr must be one of CRED, DEBT, SHAR, SLEV; got \"{chrg_br}\""),
));
}
} else {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
Severity::Error,
"CBPR_CHRGBR_REQUIRED",
"CBPR+ requires ChrgBr (one of CRED, DEBT, SHAR, SLEV)",
));
}
if !has_element(xml, "IntrBkSttlmDt") {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmDt",
Severity::Error,
"CBPR_STTLM_DT_REQUIRED",
"CBPR+ requires IntrBkSttlmDt",
));
}
for bic in extract_all_elements(xml, "BICFI") {
if bic.len() == 8 {
errors.push(ValidationError::new(
"//BICFI",
Severity::Warning,
"CBPR_BIC_PADDING",
format!(
"CBPR+ recommends 11-character BICs; \"{bic}\" is 8 characters (pad with XXX)"
),
));
}
}
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 CbprPlusValidator {
#[allow(clippy::unused_self)]
fn validate_pacs008_typed(
&self,
doc: &mx20022_model::generated::pacs::pacs_008_001_13::Document,
) -> ValidationResult {
let mut errors: Vec<ValidationError> = Vec::new();
let msg = &doc.fi_to_fi_cstmr_cdt_trf;
check_bic_typed(
msg.grp_hdr.instg_agt.as_ref(),
"InstgAgt",
"/Document/FIToFICstmrCdtTrf/GrpHdr/InstgAgt/FinInstnId/BICFI",
"CBPR_INSTG_AGT_BIC",
&mut errors,
);
check_bic_typed(
msg.grp_hdr.instd_agt.as_ref(),
"InstdAgt",
"/Document/FIToFICstmrCdtTrf/GrpHdr/InstdAgt/FinInstnId/BICFI",
"CBPR_INSTD_AGT_BIC",
&mut errors,
);
for agent in [
msg.grp_hdr.instg_agt.as_ref(),
msg.grp_hdr.instd_agt.as_ref(),
]
.into_iter()
.flatten()
{
if let Some(bic) = &agent.fin_instn_id.bicfi {
if bic.0.len() == 8 {
errors.push(ValidationError::new(
"//BICFI",
Severity::Warning,
"CBPR_BIC_PADDING",
format!(
"CBPR+ recommends 11-character BICs; \"{}\" is 8 characters (pad with XXX)",
bic.0
),
));
}
}
}
for tx in &msg.cdt_trf_tx_inf {
if tx.dbtr_agt.fin_instn_id.bicfi.is_none() {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/DbtrAgt/FinInstnId/BICFI",
Severity::Error,
"CBPR_DBTR_AGT_BIC",
"DbtrAgt/FinInstnId/BICFI is required for CBPR+",
));
}
if tx.cdtr_agt.fin_instn_id.bicfi.is_none() {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/CdtrAgt/FinInstnId/BICFI",
Severity::Error,
"CBPR_CDTR_AGT_BIC",
"CdtrAgt/FinInstnId/BICFI is required for CBPR+",
));
}
for bic in [
tx.dbtr_agt.fin_instn_id.bicfi.as_ref(),
tx.cdtr_agt.fin_instn_id.bicfi.as_ref(),
]
.into_iter()
.flatten()
{
if bic.0.len() == 8 {
errors.push(ValidationError::new(
"//BICFI",
Severity::Warning,
"CBPR_BIC_PADDING",
format!(
"CBPR+ recommends 11-character BICs; \"{}\" is 8 characters (pad with XXX)",
bic.0
),
));
}
}
if tx.dbtr.nm.is_none() {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm",
Severity::Error,
"CBPR_DBTR_NM_REQUIRED",
"Dbtr/Nm is required for CBPR+",
));
}
if tx.cdtr.nm.is_none() {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm",
Severity::Error,
"CBPR_CDTR_NM_REQUIRED",
"Cdtr/Nm is required for CBPR+",
));
}
if tx.pmt_id.uetr.is_none() {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
Severity::Error,
"CBPR_UETR_REQUIRED",
"CBPR+ requires a UETR in PmtId",
));
}
if tx.intr_bk_sttlm_dt.is_none() {
errors.push(ValidationError::new(
"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmDt",
Severity::Error,
"CBPR_STTLM_DT_REQUIRED",
"CBPR+ requires IntrBkSttlmDt",
));
}
}
ValidationResult::new(errors)
}
}
fn check_bic_in_parent(
xml: &str,
parent_tag: &str,
path: &str,
rule_id: &str,
errors: &mut Vec<ValidationError>,
) {
super::common::check_bic_in_parent(xml, parent_tag, path, rule_id, "CBPR+", errors);
}
fn check_name_required(
xml: &str,
parent_tag: &str,
rule_id: &str,
errors: &mut Vec<ValidationError>,
) {
let path = format!("/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/{parent_tag}");
super::common::check_name_in_parent(
xml, parent_tag, None, &path, rule_id, "CBPR+", errors, true,
);
}
fn check_bic_typed(
agent: Option<
&mx20022_model::generated::pacs::pacs_008_001_13::BranchAndFinancialInstitutionIdentification8,
>,
parent_name: &str,
path: &str,
rule_id: &str,
errors: &mut Vec<ValidationError>,
) {
match agent {
None => {
errors.push(ValidationError::new(
path,
Severity::Error,
rule_id,
format!("{parent_name}/FinInstnId/BICFI is required for CBPR+ but the parent element is missing"),
));
}
Some(agt) if agt.fin_instn_id.bicfi.is_none() => {
errors.push(ValidationError::new(
path,
Severity::Error,
rule_id,
format!("{parent_name}/FinInstnId/BICFI is required for CBPR+"),
));
}
Some(_) => {}
}
}
fn check_control_characters(xml: &str, errors: &mut Vec<ValidationError>) {
for (i, c) in xml.char_indices() {
if c.is_control() && !matches!(c, '\n' | '\r' | '\t') {
errors.push(ValidationError::new(
"/Document",
Severity::Error,
"CBPR_CONTROL_CHAR",
format!(
"Disallowed control character U+{:04X} at byte offset {i}",
c as u32
),
));
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_is_cbpr_plus() {
assert_eq!(CbprPlusValidator::new().name(), "CBPR+");
}
#[test]
fn supports_pacs008() {
let v = CbprPlusValidator::new();
assert!(v.supported_messages().contains(&"pacs.008"));
}
#[test]
fn unsupported_message_returns_empty() {
let v = CbprPlusValidator::new();
let result = v.validate("<xml/>", "pain.001.001.09");
assert!(result.errors.is_empty());
}
#[test]
fn control_character_produces_error() {
let mut errors = Vec::new();
check_control_characters("hello\x01world", &mut errors);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].rule_id, "CBPR_CONTROL_CHAR");
}
#[test]
fn allowed_whitespace_is_fine() {
let mut errors = Vec::new();
check_control_characters("hello\nworld\r\n\t", &mut errors);
assert!(errors.is_empty());
}
}