Skip to main content

mx20022_validate/schemes/
cbpr.rs

1//! CBPR+ (Cross-Border Payments and Reporting Plus) scheme validator.
2//!
3//! Enforces Swift's CBPR+ usage guidelines for pacs.008 and related messages:
4//!
5//! - Business Application Header (`AppHdr`) is mandatory.
6//! - Instructing and instructed agent BICs are required.
7//! - Debtor agent and creditor agent BICs are required.
8//! - Debtor and creditor names are required.
9//! - UETR is mandatory in `PmtId`.
10//! - End-to-end ID is mandatory.
11//! - Charges bearer (`ChrgBr`) is required and must be one of: CRED, DEBT,
12//!   SHAR, SLEV.
13//! - Interbank settlement date is required.
14//! - All BICs should be 11 characters (8-char BICs generate a warning).
15//! - UTF-8 only; no control characters other than LF, CR, TAB.
16
17use 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
23/// CBPR+ scheme validator.
24///
25/// # Examples
26///
27/// ```
28/// use mx20022_validate::schemes::cbpr::CbprPlusValidator;
29/// use mx20022_validate::schemes::SchemeValidator;
30///
31/// let validator = CbprPlusValidator::new();
32/// assert_eq!(validator.name(), "CBPR+");
33/// assert!(validator.supported_messages().contains(&"pacs.008"));
34/// ```
35pub struct CbprPlusValidator;
36
37impl CbprPlusValidator {
38    /// Create a new `CbprPlusValidator`.
39    pub fn new() -> Self {
40        Self
41    }
42}
43
44impl Default for CbprPlusValidator {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50/// Valid `ChrgBr` values under CBPR+.
51const 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        // --- UTF-8 control character check ----------------------------------
74        // The XML string is already valid UTF-8 (Rust strings), so we only need
75        // to look for disallowed control characters.
76        check_control_characters(xml, &mut errors);
77
78        // --- Business Application Header ------------------------------------
79        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        // Field-level checks are pacs.008-specific.
89        if short_type != "pacs.008" {
90            return ValidationResult::new(errors);
91        }
92
93        // --- Mandatory BICs -------------------------------------------------
94        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        // --- Debtor and creditor names required -----------------------------
121        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        // --- UETR required --------------------------------------------------
125        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        // --- End-to-end ID required -----------------------------------------
135        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        // --- ChrgBr required and must be valid ------------------------------
145        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        // --- Interbank settlement date required -----------------------------
164        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        // --- BIC padding check (8-char BICs should be 11) ------------------
174        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    /// Typed validation for pacs.008 messages under CBPR+ rules.
210    #[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        // --- Instructing agent BIC required (GrpHdr level) ------------------
219        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        // --- Instructed agent BIC required (GrpHdr level) -------------------
228        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        // --- BIC padding check for GrpHdr-level agents ---
237        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            // --- Debtor agent BIC required ----------------------------------
261            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            // --- Creditor agent BIC required --------------------------------
271            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            // --- BIC padding check (8-char BICs should be 11) ---------------
281            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            // --- Debtor name required ---------------------------------------
302            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            // --- Creditor name required -------------------------------------
312            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            // --- UETR required ----------------------------------------------
322            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            // --- End-to-end ID is a required field (always present) ----------
332            // (Max35Text is required on PaymentIdentification13, nothing to
333            // check beyond XSD.)
334
335            // ChrgBr validity is enforced by the ChargeBearerType1Code enum — all
336            // variants (Cred, Debt, Shar, Slev) are valid for CBPR+.
337
338            // --- Interbank settlement date required -------------------------
339            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        // Note: AppHdr check and UTF-8 control character check require raw
350        // XML context and are not covered by the typed path.
351
352        ValidationResult::new(errors)
353    }
354}
355
356/// Check that a `BICFI` element exists inside a parent block (XML scan).
357fn 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
367/// Check that a `Nm` element exists inside a parent block (XML scan).
368fn 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
380/// Check that a BIC is present in an optional agent struct (typed).
381fn 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
411/// Check for disallowed control characters in the XML string.
412///
413/// CBPR+ requires UTF-8 encoding with no control characters except:
414/// - LF (0x0A)
415/// - CR (0x0D)
416/// - TAB (0x09)
417fn 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            // Report only the first occurrence to avoid noise.
430            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}