swift_mt_message/messages/
mt941.rs

1use crate::errors::SwiftValidationError;
2use crate::fields::*;
3use crate::parser::utils::*;
4use serde::{Deserialize, Serialize};
5
6/// **MT941: Balance Report**
7///
8/// Balance reporting with optional transaction summaries.
9///
10/// **Usage:** Balance monitoring, cash management without detailed transactions
11/// **Category:** Category 9 (Cash Management & Customer Status)
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct MT941 {
14    /// Transaction Reference Number (Field 20)
15    #[serde(rename = "20")]
16    pub field_20: Field20,
17
18    /// Related Reference (Field 21)
19    #[serde(rename = "21", skip_serializing_if = "Option::is_none")]
20    pub field_21: Option<Field21NoOption>,
21
22    /// Account Identification (Field 25)
23    #[serde(rename = "25")]
24    pub field_25: Field25AccountIdentification,
25
26    /// Statement Number/Sequence Number (Field 28)
27    #[serde(rename = "28")]
28    pub field_28: Field28,
29
30    /// Date/Time Indication (Field 13D)
31    #[serde(rename = "13D", skip_serializing_if = "Option::is_none")]
32    pub field_13d: Option<Field13D>,
33
34    /// Opening Balance (Field 60F)
35    #[serde(rename = "60F", skip_serializing_if = "Option::is_none")]
36    pub field_60f: Option<Field60F>,
37
38    /// Number and Sum of Debits (Field 90D)
39    #[serde(rename = "90D", skip_serializing_if = "Option::is_none")]
40    pub field_90d: Option<Field90D>,
41
42    /// Number and Sum of Credits (Field 90C)
43    #[serde(rename = "90C", skip_serializing_if = "Option::is_none")]
44    pub field_90c: Option<Field90C>,
45
46    /// Closing Balance (Field 62F)
47    #[serde(rename = "62F")]
48    pub field_62f: Field62F,
49
50    /// Closing Available Balance (Field 64)
51    #[serde(rename = "64", skip_serializing_if = "Option::is_none")]
52    pub field_64: Option<Field64>,
53
54    /// Forward Available Balance (Field 65)
55    #[serde(rename = "65", skip_serializing_if = "Option::is_none")]
56    pub field_65: Option<Vec<Field65>>,
57
58    /// Information to Account Owner (Field 86)
59    #[serde(rename = "86", skip_serializing_if = "Option::is_none")]
60    pub field_86: Option<Field86>,
61}
62
63impl MT941 {
64    /// Parse message from Block 4 content
65    pub fn parse_from_block4(block4: &str) -> Result<Self, crate::errors::ParseError> {
66        let mut parser = crate::parser::MessageParser::new(block4, "941");
67
68        // Parse mandatory fields
69        let field_20 = parser.parse_field::<Field20>("20")?;
70        let field_21 = parser.parse_optional_field::<Field21NoOption>("21")?;
71        let field_25 = parser.parse_field::<Field25AccountIdentification>("25")?;
72        let field_28 = parser.parse_field::<Field28>("28")?;
73
74        // Parse optional date/time indication
75        let field_13d = parser.parse_optional_field::<Field13D>("13D")?;
76
77        // Parse optional opening balance
78        let field_60f = parser.parse_optional_field::<Field60F>("60F")?;
79
80        // Parse optional summary fields
81        let field_90d = parser.parse_optional_field::<Field90D>("90D")?;
82        let field_90c = parser.parse_optional_field::<Field90C>("90C")?;
83
84        // Parse mandatory closing balance
85        let field_62f = parser.parse_field::<Field62F>("62F")?;
86
87        // Parse optional available balance
88        let field_64 = parser.parse_optional_field::<Field64>("64")?;
89
90        // Parse optional forward available balance (can be repetitive)
91        let mut field_65_vec = Vec::new();
92        while parser.detect_field("65") {
93            if let Ok(field) = parser.parse_field::<Field65>("65") {
94                field_65_vec.push(field);
95            } else {
96                break;
97            }
98        }
99        let field_65 = if field_65_vec.is_empty() {
100            None
101        } else {
102            Some(field_65_vec)
103        };
104
105        // Parse optional information field
106        let field_86 = parser.parse_optional_field::<Field86>("86")?;
107
108        Ok(MT941 {
109            field_20,
110            field_21,
111            field_25,
112            field_28,
113            field_13d,
114            field_60f,
115            field_90d,
116            field_90c,
117            field_62f,
118            field_64,
119            field_65,
120            field_86,
121        })
122    }
123
124    // ========================================================================
125    // NETWORK VALIDATION RULES (SR 2025 MT941)
126    // ========================================================================
127
128    // No validation constants needed for MT941 - only currency consistency check
129
130    // ========================================================================
131    // HELPER METHODS
132    // ========================================================================
133
134    /// Get the base currency (first two characters) from mandatory field 62F
135    fn get_base_currency(&self) -> &str {
136        &self.field_62f.currency[0..2]
137    }
138
139    // ========================================================================
140    // VALIDATION RULES
141    // ========================================================================
142
143    /// C1: Currency Code Consistency (Error code: C27)
144    /// The first two characters of the three character currency code in fields 60F, 90D,
145    /// 90C, 62F, 64 and 65 must be the same for all occurrences of these fields
146    fn validate_c1_currency_consistency(
147        &self,
148        stop_on_first_error: bool,
149    ) -> Vec<SwiftValidationError> {
150        let mut errors = Vec::new();
151        let base_currency = self.get_base_currency();
152
153        // Check 60F if present
154        if let Some(ref field_60f) = self.field_60f
155            && &field_60f.currency[0..2] != base_currency
156        {
157            errors.push(SwiftValidationError::content_error(
158                    "C27",
159                    "60F",
160                    &field_60f.currency,
161                    &format!(
162                        "Currency code in field 60F ({}) must have the same first two characters as field 62F ({})",
163                        &field_60f.currency[0..2],
164                        base_currency
165                    ),
166                    "The first two characters of the three character currency code in fields 60F, 90D, 90C, 62F, 64 and 65 must be the same for all occurrences of these fields",
167                ));
168            if stop_on_first_error {
169                return errors;
170            }
171        }
172
173        // Check 90D if present
174        if let Some(ref field_90d) = self.field_90d
175            && &field_90d.currency[0..2] != base_currency
176        {
177            errors.push(SwiftValidationError::content_error(
178                    "C27",
179                    "90D",
180                    &field_90d.currency,
181                    &format!(
182                        "Currency code in field 90D ({}) must have the same first two characters as field 62F ({})",
183                        &field_90d.currency[0..2],
184                        base_currency
185                    ),
186                    "The first two characters of the three character currency code in fields 60F, 90D, 90C, 62F, 64 and 65 must be the same for all occurrences of these fields",
187                ));
188            if stop_on_first_error {
189                return errors;
190            }
191        }
192
193        // Check 90C if present
194        if let Some(ref field_90c) = self.field_90c
195            && &field_90c.currency[0..2] != base_currency
196        {
197            errors.push(SwiftValidationError::content_error(
198                    "C27",
199                    "90C",
200                    &field_90c.currency,
201                    &format!(
202                        "Currency code in field 90C ({}) must have the same first two characters as field 62F ({})",
203                        &field_90c.currency[0..2],
204                        base_currency
205                    ),
206                    "The first two characters of the three character currency code in fields 60F, 90D, 90C, 62F, 64 and 65 must be the same for all occurrences of these fields",
207                ));
208            if stop_on_first_error {
209                return errors;
210            }
211        }
212
213        // Check 64 if present
214        if let Some(ref field_64) = self.field_64
215            && &field_64.currency[0..2] != base_currency
216        {
217            errors.push(SwiftValidationError::content_error(
218                    "C27",
219                    "64",
220                    &field_64.currency,
221                    &format!(
222                        "Currency code in field 64 ({}) must have the same first two characters as field 62F ({})",
223                        &field_64.currency[0..2],
224                        base_currency
225                    ),
226                    "The first two characters of the three character currency code in fields 60F, 90D, 90C, 62F, 64 and 65 must be the same for all occurrences of these fields",
227                ));
228            if stop_on_first_error {
229                return errors;
230            }
231        }
232
233        // Check 65 if present (can be repetitive)
234        if let Some(ref field_65_vec) = self.field_65 {
235            for (idx, field_65) in field_65_vec.iter().enumerate() {
236                if &field_65.currency[0..2] != base_currency {
237                    errors.push(SwiftValidationError::content_error(
238                        "C27",
239                        "65",
240                        &field_65.currency,
241                        &format!(
242                            "Currency code in field 65[{}] ({}) must have the same first two characters as field 62F ({})",
243                            idx,
244                            &field_65.currency[0..2],
245                            base_currency
246                        ),
247                        "The first two characters of the three character currency code in fields 60F, 90D, 90C, 62F, 64 and 65 must be the same for all occurrences of these fields",
248                    ));
249                    if stop_on_first_error {
250                        return errors;
251                    }
252                }
253            }
254        }
255
256        errors
257    }
258
259    /// Main validation method - validates all network rules
260    /// Returns array of validation errors, respects stop_on_first_error flag
261    pub fn validate_network_rules(&self, stop_on_first_error: bool) -> Vec<SwiftValidationError> {
262        let mut all_errors = Vec::new();
263
264        // C1: Currency Code Consistency
265        let c1_errors = self.validate_c1_currency_consistency(stop_on_first_error);
266        all_errors.extend(c1_errors);
267        if stop_on_first_error && !all_errors.is_empty() {
268            return all_errors;
269        }
270
271        all_errors
272    }
273}
274
275// Implement the SwiftMessageBody trait for MT941
276impl crate::traits::SwiftMessageBody for MT941 {
277    fn message_type() -> &'static str {
278        "941"
279    }
280
281    fn parse_from_block4(block4: &str) -> Result<Self, crate::errors::ParseError> {
282        Self::parse_from_block4(block4)
283    }
284
285    fn to_mt_string(&self) -> String {
286        let mut result = String::new();
287
288        append_field(&mut result, &self.field_20);
289        append_optional_field(&mut result, &self.field_21);
290        append_field(&mut result, &self.field_25);
291        append_field(&mut result, &self.field_28);
292        append_optional_field(&mut result, &self.field_13d);
293        append_optional_field(&mut result, &self.field_60f);
294        append_optional_field(&mut result, &self.field_90d);
295        append_optional_field(&mut result, &self.field_90c);
296        append_field(&mut result, &self.field_62f);
297        append_optional_field(&mut result, &self.field_64);
298        append_vec_field(&mut result, &self.field_65);
299        append_optional_field(&mut result, &self.field_86);
300
301        finalize_mt_string(result, false)
302    }
303
304    fn validate_network_rules(&self, stop_on_first_error: bool) -> Vec<SwiftValidationError> {
305        // Call the existing public method implementation
306        MT941::validate_network_rules(self, stop_on_first_error)
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::traits::SwiftField;
314
315    #[test]
316    fn test_mt941_validate_c1_currency_consistency_pass() {
317        // Valid message - all currency codes have the same first two characters
318        let mt941 = MT941 {
319            field_20: Field20::parse("BALREP001").unwrap(),
320            field_21: None,
321            field_25: Field25AccountIdentification::parse("1234567890").unwrap(),
322            field_28: Field28::parse("1").unwrap(),
323            field_13d: None,
324            field_60f: Some(Field60F::parse("C251003EUR595771,95").unwrap()),
325            field_90d: Some(Field90D::parse("72EUR385920,").unwrap()),
326            field_90c: Some(Field90C::parse("44EUR450000,").unwrap()),
327            field_62f: Field62F::parse("C251003EUR659851,95").unwrap(),
328            field_64: Some(Field64::parse("C251003EUR480525,87").unwrap()),
329            field_65: Some(vec![Field65::parse("C251004EUR530691,95").unwrap()]),
330            field_86: None,
331        };
332
333        let errors = mt941.validate_network_rules(false);
334        assert!(
335            errors.is_empty(),
336            "Expected no validation errors, got: {:?}",
337            errors
338        );
339    }
340
341    #[test]
342    fn test_mt941_validate_c1_currency_consistency_fail_60f() {
343        // Invalid message - field 60F has different currency prefix
344        let mt941 = MT941 {
345            field_20: Field20::parse("BALREP001").unwrap(),
346            field_21: None,
347            field_25: Field25AccountIdentification::parse("1234567890").unwrap(),
348            field_28: Field28::parse("1").unwrap(),
349            field_13d: None,
350            field_60f: Some(Field60F::parse("C251003USD595771,95").unwrap()), // USD instead of EUR
351            field_90d: None,
352            field_90c: None,
353            field_62f: Field62F::parse("C251003EUR659851,95").unwrap(),
354            field_64: None,
355            field_65: None,
356            field_86: None,
357        };
358
359        let errors = mt941.validate_network_rules(false);
360        assert_eq!(errors.len(), 1);
361        assert!(errors[0].message().contains("60F"));
362        assert!(errors[0].message().contains("US"));
363        assert!(errors[0].message().contains("EU"));
364    }
365
366    #[test]
367    fn test_mt941_validate_c1_currency_consistency_fail_multiple() {
368        // Invalid message - multiple fields have different currency prefixes
369        let mt941 = MT941 {
370            field_20: Field20::parse("BALREP001").unwrap(),
371            field_21: None,
372            field_25: Field25AccountIdentification::parse("1234567890").unwrap(),
373            field_28: Field28::parse("1").unwrap(),
374            field_13d: None,
375            field_60f: Some(Field60F::parse("C251003USD595771,95").unwrap()), // USD
376            field_90d: Some(Field90D::parse("72GBP385920,").unwrap()),        // GBP
377            field_90c: Some(Field90C::parse("44JPY450000,").unwrap()),        // JPY
378            field_62f: Field62F::parse("C251003EUR659851,95").unwrap(),       // EUR
379            field_64: Some(Field64::parse("C251003CHF480525,87").unwrap()),   // CHF
380            field_65: Some(vec![
381                Field65::parse("C251004AUD530691,95").unwrap(), // AUD
382                Field65::parse("C251005CAD530691,95").unwrap(), // CAD
383            ]),
384            field_86: None,
385        };
386
387        let errors = mt941.validate_network_rules(false);
388        assert_eq!(errors.len(), 6); // 60F, 90D, 90C, 64, 65[0], 65[1]
389    }
390
391    #[test]
392    fn test_mt941_validate_c1_stop_on_first_error() {
393        // Invalid message - multiple fields have different currency prefixes
394        let mt941 = MT941 {
395            field_20: Field20::parse("BALREP001").unwrap(),
396            field_21: None,
397            field_25: Field25AccountIdentification::parse("1234567890").unwrap(),
398            field_28: Field28::parse("1").unwrap(),
399            field_13d: None,
400            field_60f: Some(Field60F::parse("C251003USD595771,95").unwrap()),
401            field_90d: Some(Field90D::parse("72GBP385920,").unwrap()),
402            field_90c: None,
403            field_62f: Field62F::parse("C251003EUR659851,95").unwrap(),
404            field_64: None,
405            field_65: None,
406            field_86: None,
407        };
408
409        let errors = mt941.validate_network_rules(true); // stop on first error
410        assert_eq!(errors.len(), 1); // Should only return the first error (60F)
411    }
412}