swift_mt_message/fields/
field25.rs

1use super::swift_utils::{parse_bic, parse_max_length, parse_swift_chars};
2use crate::errors::ParseError;
3use crate::traits::SwiftField;
4use serde::{Deserialize, Serialize};
5
6/// **Field 25 NoOption: Authorisation**
7///
8/// Security authorization code or digital signature for payment authentication.
9///
10/// **Format:** `35x` (max 35 chars)
11///
12/// **Example:**
13/// ```text
14/// :25:/AUTH123456789
15/// ```
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct Field25NoOption {
18    /// Authorization code or digital signature (max 35 chars)
19    pub authorisation: String,
20}
21
22impl SwiftField for Field25NoOption {
23    fn parse(input: &str) -> crate::Result<Self>
24    where
25        Self: Sized,
26    {
27        // Strip leading slash delimiter (MT format) - JSON should not contain delimiters
28        let input_stripped = input.strip_prefix('/').unwrap_or(input);
29
30        // Parse as 35x - up to 35 SWIFT characters
31        let authorisation = parse_max_length(input_stripped, 35, "Field 25 authorisation")?;
32        parse_swift_chars(&authorisation, "Field 25 authorisation")?;
33
34        Ok(Field25NoOption { authorisation })
35    }
36
37    fn to_swift_string(&self) -> String {
38        // Add leading slash for MT format if not already present
39        if self.authorisation.starts_with('/') {
40            format!(":25:{}", self.authorisation)
41        } else {
42            format!(":25:/{}", self.authorisation)
43        }
44    }
45}
46
47/// **Field 25A: Account Identifier**
48///
49/// Simple account identification with slash prefix.
50///
51/// **Format:** `/34x` (slash + max 34 chars)
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct Field25A {
54    /// Account identifier (max 34 chars, stored without slash)
55    pub account: String,
56}
57
58impl SwiftField for Field25A {
59    fn parse(input: &str) -> crate::Result<Self>
60    where
61        Self: Sized,
62    {
63        // Must start with slash
64        if !input.starts_with('/') {
65            return Err(ParseError::InvalidFormat {
66                message: "Field 25A must start with '/'".to_string(),
67            });
68        }
69
70        // Parse account after slash (up to 34 characters)
71        let account_part = &input[1..];
72        if account_part.is_empty() {
73            return Err(ParseError::InvalidFormat {
74                message: "Field 25A account cannot be empty after '/'".to_string(),
75            });
76        }
77
78        if account_part.len() > 34 {
79            return Err(ParseError::InvalidFormat {
80                message: format!(
81                    "Field 25A account must not exceed 34 characters, found {}",
82                    account_part.len()
83                ),
84            });
85        }
86
87        parse_swift_chars(account_part, "Field 25A account")?;
88
89        Ok(Field25A {
90            account: account_part.to_string(), // Store without the slash prefix
91        })
92    }
93
94    fn to_swift_string(&self) -> String {
95        // Ensure account starts with '/' for SWIFT format
96        if self.account.starts_with('/') {
97            format!(":25A:{}", self.account)
98        } else {
99            format!(":25A:/{}", self.account)
100        }
101    }
102}
103
104/// **Field 25P: Account with Institution**
105///
106/// Account identification with associated financial institution BIC.
107///
108/// **Format:** `35x` + `4!a2!a2!c[3!c]` (account + BIC)
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct Field25P {
111    /// Account identifier (max 35 chars)
112    pub account: String,
113
114    /// Bank Identifier Code (8 or 11 chars)
115    pub bic: String,
116}
117
118impl SwiftField for Field25P {
119    fn parse(input: &str) -> crate::Result<Self>
120    where
121        Self: Sized,
122    {
123        // Field25P has account on first line and BIC on second
124        let lines: Vec<&str> = input.split('\n').collect();
125
126        if lines.is_empty() {
127            return Err(ParseError::InvalidFormat {
128                message: "Field 25P cannot be empty".to_string(),
129            });
130        }
131
132        // Parse account (first line, up to 35 characters)
133        let account = parse_max_length(lines[0], 35, "Field 25P account")?;
134        parse_swift_chars(&account, "Field 25P account")?;
135
136        // Parse BIC (second line if present, otherwise might be concatenated)
137        let bic = if lines.len() > 1 {
138            parse_bic(lines[1])?
139        } else {
140            // Try to extract BIC from the end (last 8 or 11 characters)
141            if input.len() > 8 {
142                let potential_bic_11 = &input[input.len().saturating_sub(11)..];
143                let potential_bic_8 = &input[input.len().saturating_sub(8)..];
144
145                // Try 11-character BIC first
146                if potential_bic_11.len() == 11
147                    && let Ok(bic) = parse_bic(potential_bic_11)
148                {
149                    // Reparse account without BIC
150                    let account_part = &input[..input.len() - 11];
151                    return Ok(Field25P {
152                        account: parse_max_length(account_part, 35, "Field 25P account")?,
153                        bic,
154                    });
155                }
156
157                // Try 8-character BIC
158                if potential_bic_8.len() == 8
159                    && let Ok(bic) = parse_bic(potential_bic_8)
160                {
161                    // Reparse account without BIC
162                    let account_part = &input[..input.len() - 8];
163                    return Ok(Field25P {
164                        account: parse_max_length(account_part, 35, "Field 25P account")?,
165                        bic,
166                    });
167                }
168            }
169
170            return Err(ParseError::InvalidFormat {
171                message: "Field 25P requires a BIC code".to_string(),
172            });
173        };
174
175        Ok(Field25P { account, bic })
176    }
177
178    fn to_swift_string(&self) -> String {
179        format!(":25P:{}\n{}", self.account, self.bic)
180    }
181}
182
183/// **Field 25: Account Identification Enum**
184///
185/// Account identification options for different specification needs.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187#[serde(untagged)]
188pub enum Field25AccountIdentification {
189    /// Authorization code
190    NoOption(Field25NoOption),
191    /// Account with BIC
192    P(Field25P),
193}
194
195impl SwiftField for Field25AccountIdentification {
196    fn parse(input: &str) -> crate::Result<Self>
197    where
198        Self: Sized,
199    {
200        // Try to determine variant based on content
201        // If it contains a newline or looks like it has a BIC at the end, it's Option P
202        if input.contains('\n')
203            || (input.len() > 8 && parse_bic(&input[input.len().saturating_sub(11)..]).is_ok())
204            || (input.len() > 8 && parse_bic(&input[input.len().saturating_sub(8)..]).is_ok())
205        {
206            Ok(Field25AccountIdentification::P(Field25P::parse(input)?))
207        } else {
208            // Otherwise treat as NoOption (authorization)
209            Ok(Field25AccountIdentification::NoOption(
210                Field25NoOption::parse(input)?,
211            ))
212        }
213    }
214
215    fn to_swift_string(&self) -> String {
216        match self {
217            Field25AccountIdentification::NoOption(field) => field.to_swift_string(),
218            Field25AccountIdentification::P(field) => field.to_swift_string(),
219        }
220    }
221}
222
223// Type alias for backward compatibility
224pub type Field25 = Field25NoOption;
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_field25_no_option() {
232        // Test without slash - JSON format
233        let field = Field25NoOption::parse("AUTH123456789").unwrap();
234        assert_eq!(field.authorisation, "AUTH123456789");
235        assert_eq!(field.to_swift_string(), ":25:/AUTH123456789"); // MT format adds slash
236
237        // Test with slash - MT format input, should strip for JSON storage
238        let field = Field25NoOption::parse("/1234567890").unwrap();
239        assert_eq!(field.authorisation, "1234567890"); // Stored without slash
240        assert_eq!(field.to_swift_string(), ":25:/1234567890"); // MT format adds slash
241
242        // Test max length
243        let long_auth = "A".repeat(35);
244        let field = Field25NoOption::parse(&long_auth).unwrap();
245        assert_eq!(field.authorisation, long_auth);
246
247        // Test too long
248        let too_long = "A".repeat(36);
249        assert!(Field25NoOption::parse(&too_long).is_err());
250    }
251
252    #[test]
253    fn test_field25a() {
254        let field = Field25A::parse("/GB82WEST12345698765432").unwrap();
255        assert_eq!(field.account, "GB82WEST12345698765432"); // Account stored without slash
256        assert_eq!(field.to_swift_string(), ":25A:/GB82WEST12345698765432"); // MT format includes field tag and slash
257
258        let field = Field25A::parse("/1234567890").unwrap();
259        assert_eq!(field.account, "1234567890"); // Account stored without slash
260
261        // Test missing slash
262        assert!(Field25A::parse("1234567890").is_err());
263
264        // Test empty account
265        assert!(Field25A::parse("/").is_err());
266
267        // Test too long
268        let too_long = format!("/{}", "A".repeat(35));
269        assert!(Field25A::parse(&too_long).is_err());
270    }
271
272    #[test]
273    fn test_field25p() {
274        // Test with newline separator
275        let field = Field25P::parse("CHF1234567890\nUBSWCHZH80A").unwrap();
276        assert_eq!(field.account, "CHF1234567890");
277        assert_eq!(field.bic, "UBSWCHZH80A");
278        assert_eq!(field.to_swift_string(), ":25P:CHF1234567890\nUBSWCHZH80A");
279
280        // Test with concatenated format (8-char BIC)
281        let field = Field25P::parse("ACCOUNT123DEUTDEFF").unwrap();
282        assert_eq!(field.account, "ACCOUNT123");
283        assert_eq!(field.bic, "DEUTDEFF");
284
285        // Test with concatenated format (11-char BIC)
286        let field = Field25P::parse("ACC456DEUTDEFFXXX").unwrap();
287        assert_eq!(field.account, "ACC456");
288        assert_eq!(field.bic, "DEUTDEFFXXX");
289    }
290
291    #[test]
292    fn test_field25_account_identification() {
293        // Test NoOption variant
294        let field = Field25AccountIdentification::parse("AUTH999").unwrap();
295        match field {
296            Field25AccountIdentification::NoOption(f) => assert_eq!(f.authorisation, "AUTH999"),
297            _ => panic!("Expected NoOption variant"),
298        }
299
300        // Test P variant with newline
301        let field = Field25AccountIdentification::parse("MYACCOUNT\nDEUTDEFF").unwrap();
302        match field {
303            Field25AccountIdentification::P(f) => {
304                assert_eq!(f.account, "MYACCOUNT");
305                assert_eq!(f.bic, "DEUTDEFF");
306            }
307            _ => panic!("Expected P variant"),
308        }
309    }
310}