swift_mt_message/fields/
field_utils.rs

1//! # Field-Specific Utility Functions
2//!
3//! Higher-level parsing utilities specific to SWIFT MT message fields.
4//! These utilities handle complex field patterns like party identifiers, multiline text,
5//! and field-specific validation logic.
6
7use super::swift_utils::parse_swift_chars;
8use crate::errors::ParseError;
9
10/// Payment method codes used in Field 57 and similar fields
11#[derive(Debug, Clone, PartialEq)]
12pub enum PaymentMethodCode {
13    /// Fedwire Transfer System
14    FW,
15    /// Real-Time Gross Settlement
16    RT,
17    /// Australian payments
18    AU,
19    /// Indian payments
20    IN,
21    /// Swiss Clearing
22    SW,
23    /// CHIPS
24    CH,
25    /// CHIPS Participant
26    CP,
27    /// Russian Central Bank
28    RU,
29}
30
31impl PaymentMethodCode {
32    /// Parse a payment method code from a string
33    pub fn parse(code: &str) -> Option<Self> {
34        match code {
35            "FW" => Some(PaymentMethodCode::FW),
36            "RT" => Some(PaymentMethodCode::RT),
37            "AU" => Some(PaymentMethodCode::AU),
38            "IN" => Some(PaymentMethodCode::IN),
39            "SW" => Some(PaymentMethodCode::SW),
40            "CH" => Some(PaymentMethodCode::CH),
41            "CP" => Some(PaymentMethodCode::CP),
42            "RU" => Some(PaymentMethodCode::RU),
43            _ => None,
44        }
45    }
46
47    /// Convert to string representation
48    pub fn as_str(&self) -> &'static str {
49        match self {
50            PaymentMethodCode::FW => "FW",
51            PaymentMethodCode::RT => "RT",
52            PaymentMethodCode::AU => "AU",
53            PaymentMethodCode::IN => "IN",
54            PaymentMethodCode::SW => "SW",
55            PaymentMethodCode::CH => "CH",
56            PaymentMethodCode::CP => "CP",
57            PaymentMethodCode::RU => "RU",
58        }
59    }
60}
61
62/// Parse payment method from //XX format
63pub fn parse_payment_method(input: &str) -> Option<PaymentMethodCode> {
64    if input.starts_with("//") && input.len() == 4 {
65        PaymentMethodCode::parse(&input[2..])
66    } else {
67        None
68    }
69}
70
71/// Transaction type codes used in Field 61 and similar fields
72#[derive(Debug, Clone, PartialEq)]
73pub enum TransactionTypeCode {
74    /// Book transfer
75    BOK,
76    /// Customer transfer
77    MSC,
78    /// Reversal of book transfer
79    RTR,
80    /// Customer cheque
81    CHK,
82    /// Draft
83    DFT,
84    /// Standing order
85    STO,
86    /// Loan transaction
87    LDP,
88    /// Foreign exchange
89    FEX,
90    /// Collection
91    COL,
92    /// Letter of credit
93    LBX,
94    /// Travellers cheques
95    TCK,
96    /// Documentary credit
97    DCR,
98    /// Cash letter
99    CSH,
100    /// Charges and other debit interest
101    CHG,
102    /// Interest
103    INT,
104    /// Dividend
105    DIV,
106}
107
108impl TransactionTypeCode {
109    /// Parse a transaction type code
110    pub fn parse(code: &str) -> Option<Self> {
111        match code {
112            "BOK" => Some(TransactionTypeCode::BOK),
113            "MSC" => Some(TransactionTypeCode::MSC),
114            "RTR" => Some(TransactionTypeCode::RTR),
115            "CHK" => Some(TransactionTypeCode::CHK),
116            "DFT" => Some(TransactionTypeCode::DFT),
117            "STO" => Some(TransactionTypeCode::STO),
118            "LDP" => Some(TransactionTypeCode::LDP),
119            "FEX" => Some(TransactionTypeCode::FEX),
120            "COL" => Some(TransactionTypeCode::COL),
121            "LBX" => Some(TransactionTypeCode::LBX),
122            "TCK" => Some(TransactionTypeCode::TCK),
123            "DCR" => Some(TransactionTypeCode::DCR),
124            "CSH" => Some(TransactionTypeCode::CSH),
125            "CHG" => Some(TransactionTypeCode::CHG),
126            "INT" => Some(TransactionTypeCode::INT),
127            "DIV" => Some(TransactionTypeCode::DIV),
128            _ => None,
129        }
130    }
131
132    /// Convert to string representation
133    pub fn as_str(&self) -> &'static str {
134        match self {
135            TransactionTypeCode::BOK => "BOK",
136            TransactionTypeCode::MSC => "MSC",
137            TransactionTypeCode::RTR => "RTR",
138            TransactionTypeCode::CHK => "CHK",
139            TransactionTypeCode::DFT => "DFT",
140            TransactionTypeCode::STO => "STO",
141            TransactionTypeCode::LDP => "LDP",
142            TransactionTypeCode::FEX => "FEX",
143            TransactionTypeCode::COL => "COL",
144            TransactionTypeCode::LBX => "LBX",
145            TransactionTypeCode::TCK => "TCK",
146            TransactionTypeCode::DCR => "DCR",
147            TransactionTypeCode::CSH => "CSH",
148            TransactionTypeCode::CHG => "CHG",
149            TransactionTypeCode::INT => "INT",
150            TransactionTypeCode::DIV => "DIV",
151        }
152    }
153}
154
155/// Bank operation codes used in Field 23 and similar fields
156#[derive(Debug, Clone, PartialEq)]
157pub enum BankOperationCode {
158    /// Credit transfer
159    CRED,
160    /// Credit reversal
161    CRTS,
162    /// Debit transfer
163    SPAY,
164    /// Debit reversal
165    SSTD,
166    /// Priority payment
167    SPRI,
168    /// Related reference
169    CHQB,
170}
171
172impl BankOperationCode {
173    /// Parse a bank operation code
174    pub fn parse(code: &str) -> Option<Self> {
175        match code {
176            "CRED" => Some(BankOperationCode::CRED),
177            "CRTS" => Some(BankOperationCode::CRTS),
178            "SPAY" => Some(BankOperationCode::SPAY),
179            "SSTD" => Some(BankOperationCode::SSTD),
180            "SPRI" => Some(BankOperationCode::SPRI),
181            "CHQB" => Some(BankOperationCode::CHQB),
182            _ => None,
183        }
184    }
185
186    /// Convert to string representation
187    pub fn as_str(&self) -> &'static str {
188        match self {
189            BankOperationCode::CRED => "CRED",
190            BankOperationCode::CRTS => "CRTS",
191            BankOperationCode::SPAY => "SPAY",
192            BankOperationCode::SSTD => "SSTD",
193            BankOperationCode::SPRI => "SPRI",
194            BankOperationCode::CHQB => "CHQB",
195        }
196    }
197}
198
199/// Validate that a field option (like 50A, 50K) matches expected options
200pub fn validate_field_option(
201    field_number: &str,
202    option: Option<char>,
203    allowed_options: &[char],
204) -> Result<(), ParseError> {
205    if let Some(opt) = option
206        && !allowed_options.contains(&opt)
207    {
208        return Err(ParseError::InvalidFormat {
209            message: format!("Field {} does not support option {}", field_number, opt),
210        });
211    }
212    Ok(())
213}
214
215/// Parse a field tag with optional variant (e.g., "50A" -> ("50", Some('A')))
216pub fn parse_field_tag(tag: &str) -> (String, Option<char>) {
217    if tag.len() >= 2 {
218        let last_char = tag.chars().last().unwrap();
219        if last_char.is_ascii_alphabetic()
220            && tag[..tag.len() - 1].chars().all(|c| c.is_ascii_digit())
221        {
222            return (tag[..tag.len() - 1].to_string(), Some(last_char));
223        }
224    }
225    (tag.to_string(), None)
226}
227
228/// Check if a line looks like a numbered field line (e.g., "1/" or "2/" at start)
229pub fn is_numbered_line(line: &str) -> bool {
230    if line.len() >= 2 {
231        let mut chars = line.chars();
232        if let Some(first) = chars.next()
233            && let Some(second) = chars.next()
234        {
235            return first.is_ascii_digit() && second == '/';
236        }
237    }
238    false
239}
240
241/// Parse numbered lines format (e.g., "1/ACCOUNT", "2/NAME")
242pub fn parse_numbered_lines(lines: &[&str]) -> Result<Vec<(u8, String)>, ParseError> {
243    let mut result = Vec::new();
244
245    for line in lines {
246        if !is_numbered_line(line) {
247            return Err(ParseError::InvalidFormat {
248                message: format!("Expected numbered line format (n/text), found: {}", line),
249            });
250        }
251
252        let number = line.chars().next().unwrap().to_digit(10).unwrap() as u8;
253        let content = &line[2..]; // Skip "n/"
254
255        result.push((number, content.to_string()));
256    }
257
258    Ok(result)
259}
260
261/// Extract the numeric part from mixed alphanumeric field (e.g., "32A" -> "32")
262pub fn extract_field_number(field_tag: &str) -> String {
263    field_tag
264        .chars()
265        .take_while(|c| c.is_ascii_digit())
266        .collect()
267}
268
269/// Parse party identifier in format /1!a/34x, /2!a/34x, //XX, or /34x
270/// Used in fields 51-59 for institutional and party identification
271pub fn parse_party_identifier(input: &str) -> Result<Option<String>, ParseError> {
272    if !input.starts_with('/') {
273        return Ok(None);
274    }
275
276    let remaining = &input[1..];
277
278    // Handle special //XX format (e.g., //FW, //RT, //AU, //IN)
279    if let Some(special_code) = remaining.strip_prefix('/') {
280        if !special_code.is_empty() && special_code.len() <= 34 {
281            parse_swift_chars(special_code, "party identifier")?;
282            return Ok(Some(format!("/{}", special_code)));
283        }
284        return Err(ParseError::InvalidFormat {
285            message: format!("Invalid special party identifier format: {}", input),
286        });
287    }
288
289    // Check for /code/identifier format
290    if let Some(slash_pos) = remaining.find('/') {
291        let code = &remaining[..slash_pos];
292        let id = &remaining[slash_pos + 1..];
293
294        // Handle /1!a/34x format (single alphabetic character)
295        if code.len() == 1 && code.chars().all(|c| c.is_ascii_alphabetic()) {
296            if id.len() > 34 {
297                return Err(ParseError::InvalidFormat {
298                    message: format!("Party identifier exceeds 34 characters: {}", id.len()),
299                });
300            }
301            parse_swift_chars(id, "party identifier")?;
302            return Ok(Some(format!("{}/{}", code, id)));
303        }
304
305        // Handle /2!a/34x format (e.g., /CH/, /FW/, /CP/)
306        if (1..=2).contains(&code.len())
307            && code
308                .chars()
309                .all(|c| c.is_ascii_alphabetic() || c.is_ascii_digit())
310        {
311            if id.len() > 34 {
312                return Err(ParseError::InvalidFormat {
313                    message: format!("Party identifier exceeds 34 characters: {}", id.len()),
314                });
315            }
316            parse_swift_chars(id, "party identifier")?;
317            return Ok(Some(format!("{}/{}", code, id)));
318        }
319    } else if remaining.len() <= 34 {
320        // Simple /34x format (no additional slash)
321        parse_swift_chars(remaining, "party identifier")?;
322        return Ok(Some(remaining.to_string()));
323    }
324
325    Err(ParseError::InvalidFormat {
326        message: format!("Invalid party identifier format: {}", input),
327    })
328}
329
330/// Parse and validate debit/credit mark (D or C)
331pub fn parse_debit_credit_mark(input: char) -> Result<String, ParseError> {
332    if input != 'D' && input != 'C' {
333        return Err(ParseError::InvalidFormat {
334            message: format!("Debit/credit mark must be 'D' or 'C', found: '{}'", input),
335        });
336    }
337    Ok(input.to_string())
338}
339
340/// Validate multi-line text with specific constraints
341/// Returns validated lines as Vec<String>
342pub fn validate_multiline_text(
343    lines: &[&str],
344    max_lines: usize,
345    max_line_length: usize,
346    field_name: &str,
347) -> Result<Vec<String>, ParseError> {
348    if lines.is_empty() {
349        return Err(ParseError::InvalidFormat {
350            message: format!("{} must have at least one line", field_name),
351        });
352    }
353
354    if lines.len() > max_lines {
355        return Err(ParseError::InvalidFormat {
356            message: format!(
357                "{} cannot have more than {} lines, found {}",
358                field_name,
359                max_lines,
360                lines.len()
361            ),
362        });
363    }
364
365    let mut result = Vec::new();
366    for (i, line) in lines.iter().enumerate() {
367        if line.len() > max_line_length {
368            return Err(ParseError::InvalidFormat {
369                message: format!(
370                    "{} line {} exceeds {} characters",
371                    field_name,
372                    i + 1,
373                    max_line_length
374                ),
375            });
376        }
377        parse_swift_chars(line, &format!("{} line {}", field_name, i + 1))?;
378        result.push(line.to_string());
379    }
380
381    Ok(result)
382}
383
384/// Parse name and address lines (4*35x format)
385/// Used in fields 50-59 for party name and address information
386pub fn parse_name_and_address(
387    lines: &[&str],
388    start_idx: usize,
389    field_name: &str,
390) -> Result<Vec<String>, ParseError> {
391    let mut name_and_address = Vec::new();
392
393    for (i, line) in lines.iter().enumerate().skip(start_idx) {
394        if line.len() > 35 {
395            return Err(ParseError::InvalidFormat {
396                message: format!(
397                    "{} line {} exceeds 35 characters",
398                    field_name,
399                    i - start_idx + 1
400                ),
401            });
402        }
403        parse_swift_chars(line, &format!("{} line {}", field_name, i - start_idx + 1))?;
404        name_and_address.push(line.to_string());
405    }
406
407    if name_and_address.is_empty() {
408        return Err(ParseError::InvalidFormat {
409            message: format!("{} must have at least one name/address line", field_name),
410        });
411    }
412
413    if name_and_address.len() > 4 {
414        return Err(ParseError::InvalidFormat {
415            message: format!(
416                "{} cannot have more than 4 name/address lines, found {}",
417                field_name,
418                name_and_address.len()
419            ),
420        });
421    }
422
423    Ok(name_and_address)
424}
425
426/// Parse multiline text (4*35x format) - simpler version for basic multiline fields
427pub fn parse_multiline_text(
428    input: &str,
429    max_lines: usize,
430    max_line_length: usize,
431) -> Result<Vec<String>, ParseError> {
432    let lines: Vec<String> = input
433        .lines()
434        .map(|s| s.to_string())
435        .filter(|s| !s.is_empty())
436        .collect();
437
438    if lines.len() > max_lines {
439        return Err(ParseError::InvalidFormat {
440            message: format!(
441                "Text exceeds maximum of {} lines, found {}",
442                max_lines,
443                lines.len()
444            ),
445        });
446    }
447
448    for (i, line) in lines.iter().enumerate() {
449        if line.len() > max_line_length {
450            return Err(ParseError::InvalidFormat {
451                message: format!(
452                    "Line {} exceeds maximum length of {} characters",
453                    i + 1,
454                    max_line_length
455                ),
456            });
457        }
458    }
459
460    Ok(lines)
461}
462
463/// Extract field option (e.g., "A" from ":50A:")
464pub fn extract_field_option(tag: &str) -> Option<char> {
465    // Format is :NNO: where NN is field number and O is optional letter
466    if tag.len() >= 5 && tag.starts_with(':') && tag.ends_with(':') {
467        let inner = &tag[1..tag.len() - 1];
468        if inner.len() == 3 && inner[0..2].chars().all(|c| c.is_numeric()) {
469            return inner.chars().nth(2);
470        }
471    }
472    None
473}
474
475/// Parse field with optional suffix (e.g., "20C" -> ("20", Some('C')))
476pub fn parse_field_with_suffix(input: &str) -> (String, Option<char>) {
477    if let Some(last_char) = input.chars().last()
478        && last_char.is_alphabetic()
479        && input[..input.len() - 1].chars().all(|c| c.is_numeric())
480    {
481        return (input[..input.len() - 1].to_string(), Some(last_char));
482    }
483    (input.to_string(), None)
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn test_payment_method_code() {
492        assert_eq!(PaymentMethodCode::parse("FW"), Some(PaymentMethodCode::FW));
493        assert_eq!(PaymentMethodCode::parse("RT"), Some(PaymentMethodCode::RT));
494        assert_eq!(PaymentMethodCode::parse("XX"), None);
495
496        assert_eq!(PaymentMethodCode::FW.as_str(), "FW");
497    }
498
499    #[test]
500    fn test_parse_payment_method() {
501        assert_eq!(parse_payment_method("//FW"), Some(PaymentMethodCode::FW));
502        assert_eq!(parse_payment_method("//RT"), Some(PaymentMethodCode::RT));
503        assert_eq!(parse_payment_method("FW"), None);
504        assert_eq!(parse_payment_method("//XXX"), None);
505    }
506
507    #[test]
508    fn test_transaction_type_code() {
509        assert_eq!(
510            TransactionTypeCode::parse("MSC"),
511            Some(TransactionTypeCode::MSC)
512        );
513        assert_eq!(
514            TransactionTypeCode::parse("CHK"),
515            Some(TransactionTypeCode::CHK)
516        );
517        assert_eq!(TransactionTypeCode::parse("XXX"), None);
518
519        assert_eq!(TransactionTypeCode::MSC.as_str(), "MSC");
520    }
521
522    #[test]
523    fn test_validate_field_option() {
524        assert!(validate_field_option("50", Some('A'), &['A', 'K', 'F']).is_ok());
525        assert!(validate_field_option("50", Some('X'), &['A', 'K', 'F']).is_err());
526        assert!(validate_field_option("50", None, &['A', 'K', 'F']).is_ok());
527    }
528
529    #[test]
530    fn test_parse_field_tag() {
531        assert_eq!(parse_field_tag("50A"), ("50".to_string(), Some('A')));
532        assert_eq!(parse_field_tag("50"), ("50".to_string(), None));
533        assert_eq!(parse_field_tag("32A"), ("32".to_string(), Some('A')));
534        assert_eq!(parse_field_tag("ABC"), ("ABC".to_string(), None));
535    }
536
537    #[test]
538    fn test_is_numbered_line() {
539        assert!(is_numbered_line("1/ACCOUNT"));
540        assert!(is_numbered_line("2/NAME"));
541        assert!(is_numbered_line("9/TEXT"));
542        assert!(!is_numbered_line("ACCOUNT"));
543        assert!(!is_numbered_line("/ACCOUNT"));
544        assert!(!is_numbered_line("A/ACCOUNT"));
545    }
546
547    #[test]
548    fn test_parse_numbered_lines() {
549        let lines = vec!["1/ACCOUNT", "2/NAME", "3/ADDRESS"];
550        let result = parse_numbered_lines(&lines).unwrap();
551        assert_eq!(result.len(), 3);
552        assert_eq!(result[0], (1, "ACCOUNT".to_string()));
553        assert_eq!(result[1], (2, "NAME".to_string()));
554        assert_eq!(result[2], (3, "ADDRESS".to_string()));
555
556        let bad_lines = vec!["1/ACCOUNT", "NAME"];
557        assert!(parse_numbered_lines(&bad_lines).is_err());
558    }
559
560    #[test]
561    fn test_extract_field_number() {
562        assert_eq!(extract_field_number("50A"), "50");
563        assert_eq!(extract_field_number("32"), "32");
564        assert_eq!(extract_field_number("103STP"), "103");
565        assert_eq!(extract_field_number("ABC"), "");
566    }
567
568    #[test]
569    fn test_parse_party_identifier() {
570        // Test /1!a/34x format
571        let result = parse_party_identifier("/D/12345678").unwrap();
572        assert_eq!(result, Some("D/12345678".to_string()));
573
574        // Test /2!a/34x format
575        let result = parse_party_identifier("/CH/123456").unwrap();
576        assert_eq!(result, Some("CH/123456".to_string()));
577
578        // Test /34x format
579        let result = parse_party_identifier("/ACCOUNT123").unwrap();
580        assert_eq!(result, Some("ACCOUNT123".to_string()));
581
582        // Test //XX format (special codes)
583        let result = parse_party_identifier("//FW123456").unwrap();
584        assert_eq!(result, Some("/FW123456".to_string()));
585
586        let result = parse_party_identifier("//RT").unwrap();
587        assert_eq!(result, Some("/RT".to_string()));
588
589        // Test no party identifier
590        let result = parse_party_identifier("NOTPARTY").unwrap();
591        assert_eq!(result, None);
592
593        // Test too long identifier
594        assert!(parse_party_identifier("/D/12345678901234567890123456789012345").is_err());
595    }
596
597    #[test]
598    fn test_parse_debit_credit_mark() {
599        assert_eq!(parse_debit_credit_mark('D').unwrap(), "D");
600        assert_eq!(parse_debit_credit_mark('C').unwrap(), "C");
601        assert!(parse_debit_credit_mark('X').is_err());
602        assert!(parse_debit_credit_mark('1').is_err());
603    }
604
605    #[test]
606    fn test_validate_multiline_text() {
607        let lines = vec!["LINE 1", "LINE 2", "LINE 3"];
608        let result = validate_multiline_text(&lines, 4, 35, "Test Field").unwrap();
609        assert_eq!(result.len(), 3);
610        assert_eq!(result[0], "LINE 1");
611
612        // Test too many lines
613        let lines = vec!["L1", "L2", "L3", "L4", "L5"];
614        assert!(validate_multiline_text(&lines, 4, 35, "Test Field").is_err());
615
616        // Test line too long
617        let lines = vec!["THIS LINE IS TOO LONG AND EXCEEDS THE 35 CHARACTER LIMIT"];
618        assert!(validate_multiline_text(&lines, 4, 35, "Test Field").is_err());
619
620        // Test empty lines
621        let lines: Vec<&str> = vec![];
622        assert!(validate_multiline_text(&lines, 4, 35, "Test Field").is_err());
623    }
624
625    #[test]
626    fn test_parse_name_and_address() {
627        let lines = vec!["PARTY ID", "JOHN DOE", "123 MAIN ST", "NEW YORK"];
628        let result = parse_name_and_address(&lines, 1, "Test Field").unwrap();
629        assert_eq!(result.len(), 3);
630        assert_eq!(result[0], "JOHN DOE");
631        assert_eq!(result[1], "123 MAIN ST");
632        assert_eq!(result[2], "NEW YORK");
633
634        // Test too many lines
635        let lines = vec!["ID", "L1", "L2", "L3", "L4", "L5"];
636        assert!(parse_name_and_address(&lines, 1, "Test Field").is_err());
637
638        // Test line too long
639        let lines = vec![
640            "ID",
641            "THIS LINE IS TOO LONG AND EXCEEDS THE 35 CHARACTER LIMIT",
642        ];
643        assert!(parse_name_and_address(&lines, 1, "Test Field").is_err());
644    }
645
646    #[test]
647    fn test_extract_field_option() {
648        assert_eq!(extract_field_option(":50A:"), Some('A'));
649        assert_eq!(extract_field_option(":50K:"), Some('K'));
650        assert_eq!(extract_field_option(":20:"), None);
651        assert_eq!(extract_field_option("50A"), None);
652    }
653
654    #[test]
655    fn test_parse_field_with_suffix() {
656        assert_eq!(
657            parse_field_with_suffix("20C"),
658            ("20".to_string(), Some('C'))
659        );
660        assert_eq!(parse_field_with_suffix("20"), ("20".to_string(), None));
661        assert_eq!(parse_field_with_suffix("ABC"), ("ABC".to_string(), None));
662    }
663}