1use std::any::Any;
18
19use super::xml_scan::{extract_all_elements, extract_element, has_element};
20use super::SchemeValidator;
21use crate::error::{Severity, ValidationError, ValidationResult};
22
23pub struct CbprPlusValidator;
36
37impl CbprPlusValidator {
38 pub fn new() -> Self {
40 Self
41 }
42}
43
44impl Default for CbprPlusValidator {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50const VALID_CHRGBR: &[&str] = &["CRED", "DEBT", "SHAR", "SLEV"];
52
53impl SchemeValidator for CbprPlusValidator {
54 fn name(&self) -> &'static str {
55 "CBPR+"
56 }
57
58 fn supported_messages(&self) -> &[&str] {
59 &[
60 "pacs.008", "pacs.009", "pacs.002", "pacs.004", "camt.056", "camt.029",
61 ]
62 }
63
64 fn validate(&self, xml: &str, message_type: &str) -> ValidationResult {
65 let short_type = super::short_message_type(message_type);
66
67 if !self.supported_messages().contains(&short_type.as_str()) {
68 return ValidationResult::default();
69 }
70
71 let mut errors: Vec<ValidationError> = Vec::new();
72
73 check_control_characters(xml, &mut errors);
77
78 if !has_element(xml, "AppHdr") && !has_element(xml, "BizMsgIdr") {
80 errors.push(ValidationError::new(
81 "/AppHdr",
82 Severity::Error,
83 "CBPR_BAH_REQUIRED",
84 "CBPR+ requires a Business Application Header (AppHdr / BizMsgIdr)",
85 ));
86 }
87
88 if short_type != "pacs.008" {
90 return ValidationResult::new(errors);
91 }
92
93 let bic_fields: &[(&str, &str, &str)] = &[
95 (
96 "InstgAgt",
97 "CBPR_INSTG_AGT_BIC",
98 "/Document/FIToFICstmrCdtTrf/GrpHdr/InstgAgt/FinInstnId/BICFI",
99 ),
100 (
101 "InstdAgt",
102 "CBPR_INSTD_AGT_BIC",
103 "/Document/FIToFICstmrCdtTrf/GrpHdr/InstdAgt/FinInstnId/BICFI",
104 ),
105 (
106 "DbtrAgt",
107 "CBPR_DBTR_AGT_BIC",
108 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/DbtrAgt/FinInstnId/BICFI",
109 ),
110 (
111 "CdtrAgt",
112 "CBPR_CDTR_AGT_BIC",
113 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/CdtrAgt/FinInstnId/BICFI",
114 ),
115 ];
116 for (parent, rule_id, path) in bic_fields {
117 check_bic_in_parent(xml, parent, path, rule_id, &mut errors);
118 }
119
120 check_name_required(xml, "Dbtr", "CBPR_DBTR_NM_REQUIRED", &mut errors);
122 check_name_required(xml, "Cdtr", "CBPR_CDTR_NM_REQUIRED", &mut errors);
123
124 if !has_element(xml, "UETR") {
126 errors.push(ValidationError::new(
127 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
128 Severity::Error,
129 "CBPR_UETR_REQUIRED",
130 "CBPR+ requires a UETR in PmtId",
131 ));
132 }
133
134 if !has_element(xml, "EndToEndId") {
136 errors.push(ValidationError::new(
137 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId",
138 Severity::Error,
139 "CBPR_E2E_REQUIRED",
140 "CBPR+ requires an EndToEndId in PmtId",
141 ));
142 }
143
144 if let Some(chrg_br) = extract_element(xml, "ChrgBr") {
146 if !VALID_CHRGBR.contains(&chrg_br) {
147 errors.push(ValidationError::new(
148 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
149 Severity::Error,
150 "CBPR_CHRGBR_VALUE",
151 format!("ChrgBr must be one of CRED, DEBT, SHAR, SLEV; got \"{chrg_br}\""),
152 ));
153 }
154 } else {
155 errors.push(ValidationError::new(
156 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr",
157 Severity::Error,
158 "CBPR_CHRGBR_REQUIRED",
159 "CBPR+ requires ChrgBr (one of CRED, DEBT, SHAR, SLEV)",
160 ));
161 }
162
163 if !has_element(xml, "IntrBkSttlmDt") {
165 errors.push(ValidationError::new(
166 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmDt",
167 Severity::Error,
168 "CBPR_STTLM_DT_REQUIRED",
169 "CBPR+ requires IntrBkSttlmDt",
170 ));
171 }
172
173 for bic in extract_all_elements(xml, "BICFI") {
175 if bic.len() == 8 {
176 errors.push(ValidationError::new(
177 "//BICFI",
178 Severity::Warning,
179 "CBPR_BIC_PADDING",
180 format!(
181 "CBPR+ recommends 11-character BICs; \"{bic}\" is 8 characters (pad with XXX)"
182 ),
183 ));
184 }
185 }
186
187 ValidationResult::new(errors)
188 }
189
190 fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option<ValidationResult> {
191 use mx20022_model::generated::pacs::pacs_008_001_13;
192
193 let short_type = super::short_message_type(message_type);
194 if !self.supported_messages().contains(&short_type.as_str()) {
195 return None;
196 }
197
198 if short_type != "pacs.008" {
199 return None;
200 }
201
202 let doc = msg.downcast_ref::<pacs_008_001_13::Document>()?;
203
204 Some(self.validate_pacs008_typed(doc))
205 }
206}
207
208impl CbprPlusValidator {
209 #[allow(clippy::unused_self)]
211 fn validate_pacs008_typed(
212 &self,
213 doc: &mx20022_model::generated::pacs::pacs_008_001_13::Document,
214 ) -> ValidationResult {
215 let mut errors: Vec<ValidationError> = Vec::new();
216 let msg = &doc.fi_to_fi_cstmr_cdt_trf;
217
218 check_bic_typed(
220 msg.grp_hdr.instg_agt.as_ref(),
221 "InstgAgt",
222 "/Document/FIToFICstmrCdtTrf/GrpHdr/InstgAgt/FinInstnId/BICFI",
223 "CBPR_INSTG_AGT_BIC",
224 &mut errors,
225 );
226
227 check_bic_typed(
229 msg.grp_hdr.instd_agt.as_ref(),
230 "InstdAgt",
231 "/Document/FIToFICstmrCdtTrf/GrpHdr/InstdAgt/FinInstnId/BICFI",
232 "CBPR_INSTD_AGT_BIC",
233 &mut errors,
234 );
235
236 for agent in [
238 msg.grp_hdr.instg_agt.as_ref(),
239 msg.grp_hdr.instd_agt.as_ref(),
240 ]
241 .into_iter()
242 .flatten()
243 {
244 if let Some(bic) = &agent.fin_instn_id.bicfi {
245 if bic.0.len() == 8 {
246 errors.push(ValidationError::new(
247 "//BICFI",
248 Severity::Warning,
249 "CBPR_BIC_PADDING",
250 format!(
251 "CBPR+ recommends 11-character BICs; \"{}\" is 8 characters (pad with XXX)",
252 bic.0
253 ),
254 ));
255 }
256 }
257 }
258
259 for tx in &msg.cdt_trf_tx_inf {
260 if tx.dbtr_agt.fin_instn_id.bicfi.is_none() {
262 errors.push(ValidationError::new(
263 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/DbtrAgt/FinInstnId/BICFI",
264 Severity::Error,
265 "CBPR_DBTR_AGT_BIC",
266 "DbtrAgt/FinInstnId/BICFI is required for CBPR+",
267 ));
268 }
269
270 if tx.cdtr_agt.fin_instn_id.bicfi.is_none() {
272 errors.push(ValidationError::new(
273 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/CdtrAgt/FinInstnId/BICFI",
274 Severity::Error,
275 "CBPR_CDTR_AGT_BIC",
276 "CdtrAgt/FinInstnId/BICFI is required for CBPR+",
277 ));
278 }
279
280 for bic in [
282 tx.dbtr_agt.fin_instn_id.bicfi.as_ref(),
283 tx.cdtr_agt.fin_instn_id.bicfi.as_ref(),
284 ]
285 .into_iter()
286 .flatten()
287 {
288 if bic.0.len() == 8 {
289 errors.push(ValidationError::new(
290 "//BICFI",
291 Severity::Warning,
292 "CBPR_BIC_PADDING",
293 format!(
294 "CBPR+ recommends 11-character BICs; \"{}\" is 8 characters (pad with XXX)",
295 bic.0
296 ),
297 ));
298 }
299 }
300
301 if tx.dbtr.nm.is_none() {
303 errors.push(ValidationError::new(
304 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm",
305 Severity::Error,
306 "CBPR_DBTR_NM_REQUIRED",
307 "Dbtr/Nm is required for CBPR+",
308 ));
309 }
310
311 if tx.cdtr.nm.is_none() {
313 errors.push(ValidationError::new(
314 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm",
315 Severity::Error,
316 "CBPR_CDTR_NM_REQUIRED",
317 "Cdtr/Nm is required for CBPR+",
318 ));
319 }
320
321 if tx.pmt_id.uetr.is_none() {
323 errors.push(ValidationError::new(
324 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR",
325 Severity::Error,
326 "CBPR_UETR_REQUIRED",
327 "CBPR+ requires a UETR in PmtId",
328 ));
329 }
330
331 if tx.intr_bk_sttlm_dt.is_none() {
340 errors.push(ValidationError::new(
341 "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmDt",
342 Severity::Error,
343 "CBPR_STTLM_DT_REQUIRED",
344 "CBPR+ requires IntrBkSttlmDt",
345 ));
346 }
347 }
348
349 ValidationResult::new(errors)
353 }
354}
355
356fn check_bic_in_parent(
358 xml: &str,
359 parent_tag: &str,
360 path: &str,
361 rule_id: &str,
362 errors: &mut Vec<ValidationError>,
363) {
364 super::common::check_bic_in_parent(xml, parent_tag, path, rule_id, "CBPR+", errors);
365}
366
367fn check_name_required(
369 xml: &str,
370 parent_tag: &str,
371 rule_id: &str,
372 errors: &mut Vec<ValidationError>,
373) {
374 let path = format!("/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/{parent_tag}");
375 super::common::check_name_in_parent(
376 xml, parent_tag, None, &path, rule_id, "CBPR+", errors, true,
377 );
378}
379
380fn check_bic_typed(
382 agent: Option<
383 &mx20022_model::generated::pacs::pacs_008_001_13::BranchAndFinancialInstitutionIdentification8,
384 >,
385 parent_name: &str,
386 path: &str,
387 rule_id: &str,
388 errors: &mut Vec<ValidationError>,
389) {
390 match agent {
391 None => {
392 errors.push(ValidationError::new(
393 path,
394 Severity::Error,
395 rule_id,
396 format!("{parent_name}/FinInstnId/BICFI is required for CBPR+ but the parent element is missing"),
397 ));
398 }
399 Some(agt) if agt.fin_instn_id.bicfi.is_none() => {
400 errors.push(ValidationError::new(
401 path,
402 Severity::Error,
403 rule_id,
404 format!("{parent_name}/FinInstnId/BICFI is required for CBPR+"),
405 ));
406 }
407 Some(_) => {}
408 }
409}
410
411fn check_control_characters(xml: &str, errors: &mut Vec<ValidationError>) {
418 for (i, c) in xml.char_indices() {
419 if c.is_control() && !matches!(c, '\n' | '\r' | '\t') {
420 errors.push(ValidationError::new(
421 "/Document",
422 Severity::Error,
423 "CBPR_CONTROL_CHAR",
424 format!(
425 "Disallowed control character U+{:04X} at byte offset {i}",
426 c as u32
427 ),
428 ));
429 break;
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn name_is_cbpr_plus() {
441 assert_eq!(CbprPlusValidator::new().name(), "CBPR+");
442 }
443
444 #[test]
445 fn supports_pacs008() {
446 let v = CbprPlusValidator::new();
447 assert!(v.supported_messages().contains(&"pacs.008"));
448 }
449
450 #[test]
451 fn unsupported_message_returns_empty() {
452 let v = CbprPlusValidator::new();
453 let result = v.validate("<xml/>", "pain.001.001.09");
454 assert!(result.errors.is_empty());
455 }
456
457 #[test]
458 fn control_character_produces_error() {
459 let mut errors = Vec::new();
460 check_control_characters("hello\x01world", &mut errors);
461 assert_eq!(errors.len(), 1);
462 assert_eq!(errors[0].rule_id, "CBPR_CONTROL_CHAR");
463 }
464
465 #[test]
466 fn allowed_whitespace_is_fine() {
467 let mut errors = Vec::new();
468 check_control_characters("hello\nworld\r\n\t", &mut errors);
469 assert!(errors.is_empty());
470 }
471}