swift_mt_message/fields/
field61.rs

1use super::swift_utils::{parse_amount, parse_date_yymmdd, parse_swift_chars};
2use crate::errors::ParseError;
3use crate::traits::SwiftField;
4use chrono::NaiveDate;
5use serde::{Deserialize, Serialize};
6
7/// **Field 61: Statement Line**
8///
9/// Individual transaction entry in account statements (MT 940).
10///
11/// **Format:** `6!n[4!n]2a[1!a]15d1!a3!c[16x][//16x][34x]`
12/// **Constraints:** D/C mark must be D, C, RD, or RC
13///
14/// **Example:**
15/// ```text
16/// :61:231225D1234,56NTRFREF123456
17/// ```
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct Field61 {
20    /// Value date (YYMMDD)
21    pub value_date: NaiveDate,
22
23    /// Entry date (MMDD, optional)
24    pub entry_date: Option<String>,
25
26    /// Debit/Credit mark (D, C, RD, or RC)
27    pub debit_credit_mark: String,
28
29    /// Funds code (optional)
30    pub funds_code: Option<char>,
31
32    /// Transaction amount
33    pub amount: f64,
34
35    /// Transaction type code (4 chars)
36    pub transaction_type: String,
37
38    /// Customer reference (max 16 chars)
39    pub customer_reference: String,
40
41    /// Bank reference (max 16 chars, optional)
42    pub bank_reference: Option<String>,
43
44    /// Supplementary details (max 34 chars, optional)
45    pub supplementary_details: Option<String>,
46}
47
48impl SwiftField for Field61 {
49    fn parse(input: &str) -> crate::Result<Self>
50    where
51        Self: Sized,
52    {
53        // Format: 6!n[4!n]2a[1!a]15d1!a3!c[16x][//16x][34x]
54        if input.len() < 15 {
55            return Err(ParseError::InvalidFormat {
56                message: "Field 61 must be at least 15 characters long".to_string(),
57            });
58        }
59
60        let mut pos = 0;
61
62        // Parse value date (6 digits, mandatory)
63        if input.len() < pos + 6 {
64            return Err(ParseError::InvalidFormat {
65                message: "Field 61 missing value date".to_string(),
66            });
67        }
68        let value_date_str = &input[pos..pos + 6];
69        let value_date = parse_date_yymmdd(value_date_str)?;
70        pos += 6;
71
72        // Parse optional entry date (4 digits)
73        let mut entry_date = None;
74        if pos + 4 <= input.len() && input[pos..pos + 4].chars().all(|c| c.is_ascii_digit()) {
75            entry_date = Some(input[pos..pos + 4].to_string());
76            pos += 4;
77        }
78
79        // Parse debit/credit mark (2 characters maximum, but could be 1)
80        if pos >= input.len() {
81            return Err(ParseError::InvalidFormat {
82                message: "Field 61 missing debit/credit mark".to_string(),
83            });
84        }
85
86        let mut dc_mark_len = 1;
87        if pos + 1 < input.len() {
88            let two_char = &input[pos..pos + 2];
89            if two_char == "RD" || two_char == "RC" {
90                dc_mark_len = 2;
91            }
92        }
93
94        let debit_credit_mark = input[pos..pos + dc_mark_len].to_string();
95        if !["D", "C", "RD", "RC"].contains(&debit_credit_mark.as_str()) {
96            return Err(ParseError::InvalidFormat {
97                message: format!("Field 61 invalid debit/credit mark: {}", debit_credit_mark),
98            });
99        }
100        pos += dc_mark_len;
101
102        // Parse optional funds code (1 character)
103        let mut funds_code = None;
104        if pos < input.len() && input.chars().nth(pos).unwrap().is_alphabetic() {
105            funds_code = Some(input.chars().nth(pos).unwrap());
106            pos += 1;
107        }
108
109        // Parse amount - find the next alphabetic character to determine where amount ends
110        let amount_start = pos;
111        while pos < input.len()
112            && (input.chars().nth(pos).unwrap().is_ascii_digit()
113                || input.chars().nth(pos).unwrap() == ','
114                || input.chars().nth(pos).unwrap() == '.')
115        {
116            pos += 1;
117        }
118
119        if pos == amount_start {
120            return Err(ParseError::InvalidFormat {
121                message: "Field 61 missing amount".to_string(),
122            });
123        }
124
125        let amount_str = &input[amount_start..pos];
126        let amount = parse_amount(amount_str)?;
127
128        // Parse transaction type (4 characters: 1!a3!c)
129        if pos + 4 > input.len() {
130            return Err(ParseError::InvalidFormat {
131                message: "Field 61 missing transaction type".to_string(),
132            });
133        }
134
135        let transaction_type = input[pos..pos + 4].to_string();
136        parse_swift_chars(&transaction_type, "Field 61 transaction type")?;
137        pos += 4;
138
139        // Parse customer reference (up to 16 characters until // or end)
140        let remaining = &input[pos..];
141        let (customer_ref_part, after_customer_ref) =
142            if let Some(double_slash_pos) = remaining.find("//") {
143                (
144                    remaining[..double_slash_pos].to_string(),
145                    Some(&remaining[double_slash_pos + 2..]),
146                )
147            } else {
148                (remaining.to_string(), None)
149            };
150
151        // Customer reference is up to 16 characters
152        let customer_reference;
153        let mut supplementary_details = None;
154
155        if customer_ref_part.len() <= 16 {
156            customer_reference = customer_ref_part;
157        } else {
158            customer_reference = customer_ref_part[..16].to_string();
159            // If customer ref part is > 16 chars and no //, rest is supplementary details
160            if after_customer_ref.is_none() && customer_ref_part.len() > 16 {
161                supplementary_details = Some(customer_ref_part[16..].to_string());
162            }
163        }
164
165        // Parse bank reference and supplementary details (after //)
166        // Format after //: bank_reference[16x][\n]supplementary_details[34x]
167        // Supplementary details may be on a new line or directly concatenated
168        let bank_reference = if let Some(bank_ref_str) = after_customer_ref {
169            // Check if there's a newline separating bank ref from supplementary details
170            if let Some(newline_pos) = bank_ref_str.find('\n') {
171                // Bank reference is before newline, supplementary details after
172                let bank_ref = bank_ref_str[..newline_pos].to_string();
173                if newline_pos + 1 < bank_ref_str.len() {
174                    supplementary_details = Some(bank_ref_str[newline_pos + 1..].to_string());
175                }
176                Some(bank_ref)
177            } else if bank_ref_str.len() > 16 {
178                // No newline, but string is longer than bank ref max
179                // First 16 chars = bank reference, rest = supplementary details
180                supplementary_details = Some(bank_ref_str[16..].to_string());
181                Some(bank_ref_str[..16].to_string())
182            } else if !bank_ref_str.is_empty() {
183                Some(bank_ref_str.to_string())
184            } else {
185                None
186            }
187        } else {
188            None
189        };
190
191        // Validate customer reference length
192        if customer_reference.len() > 16 {
193            return Err(ParseError::InvalidFormat {
194                message: "Field 61 customer reference exceeds 16 characters".to_string(),
195            });
196        }
197
198        parse_swift_chars(&customer_reference, "Field 61 customer reference")?;
199
200        if let Some(ref bank_ref) = bank_reference {
201            parse_swift_chars(bank_ref, "Field 61 bank reference")?;
202        }
203
204        if let Some(ref supp_details) = supplementary_details {
205            if supp_details.len() > 34 {
206                return Err(ParseError::InvalidFormat {
207                    message: "Field 61 supplementary details exceed 34 characters".to_string(),
208                });
209            }
210            parse_swift_chars(supp_details, "Field 61 supplementary details")?;
211        }
212
213        Ok(Field61 {
214            value_date,
215            entry_date,
216            debit_credit_mark,
217            funds_code,
218            amount,
219            transaction_type,
220            customer_reference,
221            bank_reference,
222            supplementary_details,
223        })
224    }
225
226    fn to_swift_string(&self) -> String {
227        let mut result = format!(":61:{}", self.value_date.format("%y%m%d"));
228
229        if let Some(ref entry_date) = self.entry_date {
230            result.push_str(entry_date);
231        }
232
233        result.push_str(&self.debit_credit_mark);
234
235        if let Some(funds_code) = self.funds_code {
236            result.push(funds_code);
237        }
238
239        result.push_str(&format!("{:.2}", self.amount).replace('.', ","));
240        result.push_str(&self.transaction_type);
241        result.push_str(&self.customer_reference);
242
243        if let Some(ref bank_reference) = self.bank_reference {
244            result.push_str("//");
245            result.push_str(bank_reference);
246
247            // Supplementary details come on new line after bank reference if present
248            if let Some(ref supplementary_details) = self.supplementary_details {
249                result.push('\n');
250                result.push_str(supplementary_details);
251            }
252        } else if let Some(ref supplementary_details) = self.supplementary_details {
253            // If no bank reference but supplementary details exist, append after customer ref
254            result.push_str(supplementary_details);
255        }
256
257        result
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use chrono::NaiveDate;
265
266    #[test]
267    fn test_field61_parse_basic() {
268        let field = Field61::parse("231225D1234,56NTRFREF123456").unwrap();
269        assert_eq!(
270            field.value_date,
271            NaiveDate::from_ymd_opt(2023, 12, 25).unwrap()
272        );
273        assert_eq!(field.entry_date, None);
274        assert_eq!(field.debit_credit_mark, "D");
275        assert_eq!(field.funds_code, None);
276        assert_eq!(field.amount, 1234.56);
277        assert_eq!(field.transaction_type, "NTRF");
278        assert_eq!(field.customer_reference, "REF123456");
279        assert_eq!(field.bank_reference, None);
280        assert_eq!(field.supplementary_details, None);
281    }
282
283    #[test]
284    fn test_field61_parse_with_entry_date() {
285        let field = Field61::parse("2312251226C500,00NTRFREF789//BANK456").unwrap();
286        assert_eq!(
287            field.value_date,
288            NaiveDate::from_ymd_opt(2023, 12, 25).unwrap()
289        );
290        assert_eq!(field.entry_date, Some("1226".to_string()));
291        assert_eq!(field.debit_credit_mark, "C");
292        assert_eq!(field.funds_code, None);
293        assert_eq!(field.amount, 500.00);
294        assert_eq!(field.transaction_type, "NTRF");
295        assert_eq!(field.customer_reference, "REF789");
296        assert_eq!(field.bank_reference, Some("BANK456".to_string()));
297    }
298
299    #[test]
300    fn test_field61_parse_with_funds_code() {
301        let field = Field61::parse("231225DF100,00NTRFCUSTREF").unwrap();
302        assert_eq!(field.debit_credit_mark, "D");
303        assert_eq!(field.funds_code, Some('F'));
304        assert_eq!(field.amount, 100.00);
305    }
306
307    #[test]
308    fn test_field61_parse_reversal() {
309        let field = Field61::parse("231225RD1000,00NTRFREVREF123").unwrap();
310        assert_eq!(field.debit_credit_mark, "RD");
311        assert_eq!(field.amount, 1000.00);
312    }
313
314    #[test]
315    fn test_field61_to_swift_string() {
316        let field = Field61 {
317            value_date: NaiveDate::from_ymd_opt(2023, 12, 25).unwrap(),
318            entry_date: Some("1226".to_string()),
319            debit_credit_mark: "C".to_string(),
320            funds_code: Some('F'),
321            amount: 1234.56,
322            transaction_type: "NTRF".to_string(),
323            customer_reference: "REF123456".to_string(),
324            bank_reference: Some("BANK789".to_string()),
325            supplementary_details: None,
326        };
327
328        assert_eq!(
329            field.to_swift_string(),
330            ":61:2312251226CF1234,56NTRFREF123456//BANK789"
331        );
332    }
333
334    #[test]
335    fn test_field61_invalid_debit_credit_mark() {
336        assert!(Field61::parse("231225X1234,56NTRFREF123").is_err());
337    }
338
339    #[test]
340    fn test_field61_too_short() {
341        assert!(Field61::parse("23122").is_err());
342    }
343
344    #[test]
345    fn test_field61_with_supplementary_details() {
346        // Test with bank reference and supplementary details (newline separated)
347        let field =
348            Field61::parse("2412201220C10000,00NMSCREF100000//BA1-1234567890\nDUPLICATE-SEQ-1")
349                .unwrap();
350        assert_eq!(field.customer_reference, "REF100000");
351        assert_eq!(field.bank_reference, Some("BA1-1234567890".to_string()));
352        assert_eq!(
353            field.supplementary_details,
354            Some("DUPLICATE-SEQ-1".to_string())
355        );
356
357        // Test round-trip
358        let swift_str = field.to_swift_string();
359        let reparsed = Field61::parse(&swift_str.replace(":61:", "")).unwrap();
360        assert_eq!(reparsed.customer_reference, field.customer_reference);
361        assert_eq!(reparsed.bank_reference, field.bank_reference);
362        assert_eq!(reparsed.supplementary_details, field.supplementary_details);
363    }
364}