swift_mt_message/messages/
mt940.rs

1use crate::errors::SwiftValidationError;
2use crate::fields::*;
3use crate::parser::utils::*;
4use serde::{Deserialize, Serialize};
5
6/// **MT940: Customer Statement**
7///
8/// Account statement with transaction details for specified period.
9/// Sent from account servicing institution to account owner.
10///
11/// **Usage:** Daily statements, account reconciliation
12/// **Category:** Category 9 (Cash Management & Customer Status)
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct MT940 {
15    /// Transaction Reference Number (Field 20)
16    #[serde(rename = "20")]
17    pub field_20: Field20,
18
19    /// Related Reference (Field 21)
20    #[serde(rename = "21", skip_serializing_if = "Option::is_none")]
21    pub field_21: Option<Field21NoOption>,
22
23    /// Account Identification (Field 25)
24    #[serde(rename = "25")]
25    pub field_25: Field25NoOption,
26
27    /// Statement Number/Sequence Number (Field 28C)
28    #[serde(rename = "28C")]
29    pub field_28c: Field28C,
30
31    /// Opening Balance (Field 60F)
32    #[serde(rename = "60F")]
33    pub field_60f: Field60F,
34
35    /// Statement lines (1-500 occurrences)
36    #[serde(rename = "statement_lines")]
37    pub statement_lines: Vec<MT940StatementLine>,
38
39    /// Closing Balance (Field 62F)
40    #[serde(rename = "62F")]
41    pub field_62f: Field62F,
42
43    /// Available Balance (Field 64)
44    #[serde(rename = "64", skip_serializing_if = "Option::is_none")]
45    pub field_64: Option<Field64>,
46
47    /// Forward Available Balance (Field 65)
48    #[serde(rename = "65", skip_serializing_if = "Option::is_none")]
49    pub field_65: Option<Vec<Field65>>,
50}
51
52/// Statement line for MT940
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct MT940StatementLine {
55    /// Statement Line (Field 61)
56    #[serde(rename = "61")]
57    pub field_61: Field61,
58
59    /// Information to Account Owner (Field 86)
60    #[serde(rename = "86", skip_serializing_if = "Option::is_none")]
61    pub field_86: Option<Field86>,
62}
63
64impl MT940 {
65    /// Parse message from Block 4 content
66    pub fn parse_from_block4(block4: &str) -> Result<Self, crate::errors::ParseError> {
67        let mut parser = crate::parser::MessageParser::new(block4, "940");
68
69        // Parse mandatory fields
70        let field_20 = parser.parse_field::<Field20>("20")?;
71        let field_21 = parser.parse_optional_field::<Field21NoOption>("21")?;
72        let field_25 = parser.parse_field::<Field25NoOption>("25")?;
73        let field_28c = parser.parse_field::<Field28C>("28C")?;
74        let field_60f = parser.parse_field::<Field60F>("60F")?;
75
76        // Enable duplicate field handling for statement lines
77        parser = parser.with_duplicates(true);
78
79        // Parse statement lines (1-500)
80        let mut statement_lines = Vec::new();
81
82        while parser.detect_field("61") && statement_lines.len() < 500 {
83            let field_61 = parser.parse_field::<Field61>("61")?;
84            let field_86 = parser.parse_optional_field::<Field86>("86")?;
85
86            statement_lines.push(MT940StatementLine { field_61, field_86 });
87        }
88
89        // Disable duplicates mode after parsing statement lines
90        parser = parser.with_duplicates(false);
91
92        // Must have at least one statement line
93        if statement_lines.is_empty() {
94            return Err(crate::errors::ParseError::InvalidFormat {
95                message: "MT940: At least one statement line (field 61) is required".to_string(),
96            });
97        }
98
99        // Parse mandatory closing balance
100        let field_62f = parser.parse_field::<Field62F>("62F")?;
101
102        // Parse optional fields
103        let field_64 = parser.parse_optional_field::<Field64>("64")?;
104
105        // Parse optional repetitive Field 65 (Forward Available Balance)
106        parser = parser.with_duplicates(true);
107        let mut forward_balances = Vec::new();
108        while let Ok(field_65) = parser.parse_field::<Field65>("65") {
109            forward_balances.push(field_65);
110        }
111
112        let field_65 = if forward_balances.is_empty() {
113            None
114        } else {
115            Some(forward_balances)
116        };
117
118        Ok(MT940 {
119            field_20,
120            field_21,
121            field_25,
122            field_28c,
123            field_60f,
124            statement_lines,
125            field_62f,
126            field_64,
127            field_65,
128        })
129    }
130
131    /// Validate the message instance according to MT940 rules
132    pub fn validate_instance(&self) -> Result<(), crate::errors::ParseError> {
133        // C1: Statement lines must occur 1-500 times
134        if self.statement_lines.is_empty() || self.statement_lines.len() > 500 {
135            return Err(crate::errors::ParseError::InvalidFormat {
136                message: format!(
137                    "MT940: Statement lines must occur 1-500 times, found {}",
138                    self.statement_lines.len()
139                ),
140            });
141        }
142
143        // C2 is automatically satisfied as fields 60F and 62F are mandatory
144
145        Ok(())
146    }
147
148    // ========================================================================
149    // NETWORK VALIDATION RULES (SR 2025 MT940)
150    // ========================================================================
151
152    // ========================================================================
153    // HELPER METHODS
154    // ========================================================================
155
156    /// Extract currency code from Field60F (Opening Balance)
157    fn get_field_60f_currency(&self) -> &str {
158        &self.field_60f.currency
159    }
160
161    /// Extract currency code from Field62F (Closing Balance)
162    fn get_field_62f_currency(&self) -> &str {
163        &self.field_62f.currency
164    }
165
166    /// Extract currency code from Field64 (Closing Available Balance)
167    fn get_field_64_currency(&self) -> Option<&str> {
168        self.field_64.as_ref().map(|f| f.currency.as_str())
169    }
170
171    /// Get first two characters of a currency code
172    fn get_currency_prefix(currency: &str) -> &str {
173        if currency.len() >= 2 {
174            &currency[0..2]
175        } else {
176            currency
177        }
178    }
179
180    // ========================================================================
181    // VALIDATION RULES (C1-C2)
182    // ========================================================================
183
184    /// C1: Field 86 Must Follow Field 61 (Error code: C24)
185    /// If field 86 is present in any occurrence of the repetitive sequence, it must be
186    /// preceded by a field 61. In addition, if field 86 is present, it must be present
187    /// on the same page (message) of the statement as the related field 61
188    ///
189    /// Note: This validation is structural and enforced during parsing. The parser ensures
190    /// that field 86 can only appear after field 61 within a statement line. The message
191    /// structure (MT940StatementLine) guarantees this relationship - field 86 can only exist
192    /// within a statement line, which always has a mandatory field 61. Therefore, this rule
193    /// is automatically satisfied by the structure and no additional validation is needed.
194    fn validate_c1_field_86_follows_61(&self) -> Vec<SwiftValidationError> {
195        // This rule is enforced by the message structure itself (MT940StatementLine)
196        // Field 86 can only exist within a statement line, which always has field 61
197        // The parser ensures correct ordering and pairing during message parsing
198        // No additional validation needed - return empty errors
199        Vec::new()
200    }
201
202    /// C2: Currency Code Consistency (Error code: C27)
203    /// The first two characters of the three character currency code in fields 60a,
204    /// 62a, 64 and 65 must be the same for all occurrences of these fields
205    fn validate_c2_currency_consistency(&self) -> Vec<SwiftValidationError> {
206        let mut errors = Vec::new();
207
208        // Get the reference currency prefix from field 60F (Opening Balance)
209        let reference_currency = self.get_field_60f_currency();
210        let reference_prefix = Self::get_currency_prefix(reference_currency);
211
212        // Check field 62F (Closing Balance)
213        let field_62f_currency = self.get_field_62f_currency();
214        let field_62f_prefix = Self::get_currency_prefix(field_62f_currency);
215
216        if field_62f_prefix != reference_prefix {
217            errors.push(SwiftValidationError::content_error(
218                "C27",
219                "62F",
220                field_62f_currency,
221                &format!(
222                    "Currency prefix in field 62F ('{}') must match the prefix in field 60F ('{}') - found '{}' vs '{}'",
223                    field_62f_prefix, reference_prefix, field_62f_currency, reference_currency
224                ),
225                "The first two characters of the currency code must be the same in fields 60a, 62a, 64 and 65",
226            ));
227        }
228
229        // Check field 64 (Closing Available Balance) if present
230        if let Some(field_64_currency) = self.get_field_64_currency() {
231            let field_64_prefix = Self::get_currency_prefix(field_64_currency);
232
233            if field_64_prefix != reference_prefix {
234                errors.push(SwiftValidationError::content_error(
235                    "C27",
236                    "64",
237                    field_64_currency,
238                    &format!(
239                        "Currency prefix in field 64 ('{}') must match the prefix in field 60F ('{}') - found '{}' vs '{}'",
240                        field_64_prefix, reference_prefix, field_64_currency, reference_currency
241                    ),
242                    "The first two characters of the currency code must be the same in fields 60a, 62a, 64 and 65",
243                ));
244            }
245        }
246
247        // Check field 65 (Forward Available Balance) if present
248        if let Some(field_65_vec) = &self.field_65 {
249            for (idx, field_65) in field_65_vec.iter().enumerate() {
250                let field_65_currency = &field_65.currency;
251                let field_65_prefix = Self::get_currency_prefix(field_65_currency);
252
253                if field_65_prefix != reference_prefix {
254                    errors.push(SwiftValidationError::content_error(
255                        "C27",
256                        "65",
257                        field_65_currency,
258                        &format!(
259                            "Currency prefix in field 65 occurrence {} ('{}') must match the prefix in field 60F ('{}') - found '{}' vs '{}'",
260                            idx + 1, field_65_prefix, reference_prefix, field_65_currency, reference_currency
261                        ),
262                        "The first two characters of the currency code must be the same in fields 60a, 62a, 64 and 65",
263                    ));
264                }
265            }
266        }
267
268        errors
269    }
270
271    /// Main validation method - validates all network rules
272    /// Returns array of validation errors, respects stop_on_first_error flag
273    pub fn validate_network_rules(&self, stop_on_first_error: bool) -> Vec<SwiftValidationError> {
274        let mut all_errors = Vec::new();
275
276        // C1: Field 86 Must Follow Field 61
277        let c1_errors = self.validate_c1_field_86_follows_61();
278        all_errors.extend(c1_errors);
279        if stop_on_first_error && !all_errors.is_empty() {
280            return all_errors;
281        }
282
283        // C2: Currency Code Consistency
284        let c2_errors = self.validate_c2_currency_consistency();
285        all_errors.extend(c2_errors);
286
287        all_errors
288    }
289}
290
291impl crate::traits::SwiftMessageBody for MT940 {
292    fn message_type() -> &'static str {
293        "940"
294    }
295
296    fn parse_from_block4(block4: &str) -> Result<Self, crate::errors::ParseError> {
297        // Call the existing public method implementation
298        MT940::parse_from_block4(block4)
299    }
300
301    fn to_mt_string(&self) -> String {
302        // Call the existing public method implementation
303        MT940::to_mt_string(self)
304    }
305
306    fn validate_network_rules(&self, stop_on_first_error: bool) -> Vec<SwiftValidationError> {
307        // Call the existing public method implementation
308        MT940::validate_network_rules(self, stop_on_first_error)
309    }
310}
311
312impl MT940 {
313    /// Convert to SWIFT MT text format
314    pub fn to_mt_string(&self) -> String {
315        let mut result = String::new();
316
317        append_field(&mut result, &self.field_20);
318        append_optional_field(&mut result, &self.field_21);
319        append_field(&mut result, &self.field_25);
320        append_field(&mut result, &self.field_28c);
321        append_field(&mut result, &self.field_60f);
322
323        // Statement lines
324        for statement_line in &self.statement_lines {
325            append_field(&mut result, &statement_line.field_61);
326            append_optional_field(&mut result, &statement_line.field_86);
327        }
328
329        append_field(&mut result, &self.field_62f);
330        append_optional_field(&mut result, &self.field_64);
331        append_vec_field(&mut result, &self.field_65);
332
333        finalize_mt_string(result, false)
334    }
335}