swift_mt_message/fields/
swift_utils.rs

1//! # Core SWIFT Utility Functions
2//!
3//! Low-level parsing utilities for SWIFT MT message primitive data types.
4//! These utilities handle basic SWIFT data formats like BIC codes, currency codes,
5//! dates, amounts, and character validation.
6
7use crate::errors::ParseError;
8use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
9
10/// Parse a string with exact length requirement
11pub fn parse_exact_length(
12    input: &str,
13    expected_len: usize,
14    field_name: &str,
15) -> Result<String, ParseError> {
16    if input.len() != expected_len {
17        return Err(ParseError::InvalidFormat {
18            message: format!(
19                "{} must be exactly {} characters, found {}",
20                field_name,
21                expected_len,
22                input.len()
23            ),
24        });
25    }
26    Ok(input.to_string())
27}
28
29/// Parse a string with maximum length limit
30pub fn parse_max_length(
31    input: &str,
32    max_len: usize,
33    field_name: &str,
34) -> Result<String, ParseError> {
35    if input.len() > max_len {
36        return Err(ParseError::InvalidFormat {
37            message: format!(
38                "{} must be at most {} characters, found {}",
39                field_name,
40                max_len,
41                input.len()
42            ),
43        });
44    }
45    Ok(input.to_string())
46}
47
48/// Parse a string with minimum and maximum length
49pub fn parse_length_range(
50    input: &str,
51    min_len: usize,
52    max_len: usize,
53    field_name: &str,
54) -> Result<String, ParseError> {
55    if input.len() < min_len || input.len() > max_len {
56        return Err(ParseError::InvalidFormat {
57            message: format!(
58                "{} must be between {} and {} characters, found {}",
59                field_name,
60                min_len,
61                max_len,
62                input.len()
63            ),
64        });
65    }
66    Ok(input.to_string())
67}
68
69/// Parse alphanumeric string (letters and digits only)
70pub fn parse_alphanumeric(input: &str, field_name: &str) -> Result<String, ParseError> {
71    if !input.chars().all(|c| c.is_alphanumeric()) {
72        return Err(ParseError::InvalidFormat {
73            message: format!("{} must contain only letters and digits", field_name),
74        });
75    }
76    Ok(input.to_string())
77}
78
79/// Parse uppercase letters only
80pub fn parse_uppercase(input: &str, field_name: &str) -> Result<String, ParseError> {
81    if !input.chars().all(|c| c.is_uppercase() || c.is_whitespace()) {
82        return Err(ParseError::InvalidFormat {
83            message: format!("{} must contain only uppercase letters", field_name),
84        });
85    }
86    Ok(input.to_string())
87}
88
89/// Parse numeric string (digits only)
90pub fn parse_numeric(input: &str, field_name: &str) -> Result<String, ParseError> {
91    if !input.chars().all(|c| c.is_numeric()) {
92        return Err(ParseError::InvalidFormat {
93            message: format!("{} must contain only digits", field_name),
94        });
95    }
96    Ok(input.to_string())
97}
98
99/// Parse SWIFT digits format (digits only, used for numeric fields)
100pub fn parse_swift_digits(input: &str, field_name: &str) -> Result<String, ParseError> {
101    if !input.chars().all(|c| c.is_ascii_digit()) {
102        return Err(ParseError::InvalidFormat {
103            message: format!("{} must contain only digits", field_name),
104        });
105    }
106    Ok(input.to_string())
107}
108
109/// Parse SWIFT character set (a-z, A-Z, 0-9, and special chars)
110///
111/// SWIFT 'x' character set includes: a-z, A-Z, 0-9, and special characters:
112/// / - ? : ( ) . , ' + { } SPACE CR LF and other printable ASCII
113pub fn parse_swift_chars(input: &str, field_name: &str) -> Result<String, ParseError> {
114    // SWIFT x character set: alphanumeric + special characters
115    // Common special chars: / - ? : ( ) . , ' + { } SPACE CR LF % & * ; < = > @ [ ] _ $ ! " # |
116    const SWIFT_SPECIAL: &str = "/-?:().,'+{} \r\n%&*;<=>@[]_$!\"#|";
117
118    if !input
119        .chars()
120        .all(|c| c.is_alphanumeric() || SWIFT_SPECIAL.contains(c))
121    {
122        return Err(ParseError::InvalidFormat {
123            message: format!(
124                "{} contains invalid characters for SWIFT format",
125                field_name
126            ),
127        });
128    }
129    Ok(input.to_string())
130}
131
132/// Parse BIC code (8 or 11 characters)
133pub fn parse_bic(input: &str) -> Result<String, ParseError> {
134    if input.len() != 8 && input.len() != 11 {
135        return Err(ParseError::InvalidFormat {
136            message: format!("BIC must be 8 or 11 characters, found {}", input.len()),
137        });
138    }
139
140    // First 4 chars: Bank code (letters)
141    if !input[0..4].chars().all(|c| c.is_alphabetic()) {
142        return Err(ParseError::InvalidFormat {
143            message: "BIC bank code (first 4 chars) must be letters".to_string(),
144        });
145    }
146
147    // Next 2 chars: Country code (letters)
148    if !input[4..6].chars().all(|c| c.is_alphabetic()) {
149        return Err(ParseError::InvalidFormat {
150            message: "BIC country code (chars 5-6) must be letters".to_string(),
151        });
152    }
153
154    // Next 2 chars: Location code (alphanumeric)
155    if !input[6..8].chars().all(|c| c.is_alphanumeric()) {
156        return Err(ParseError::InvalidFormat {
157            message: "BIC location code (chars 7-8) must be alphanumeric".to_string(),
158        });
159    }
160
161    // Optional 3 chars: Branch code (alphanumeric)
162    if input.len() == 11 && !input[8..11].chars().all(|c| c.is_alphanumeric()) {
163        return Err(ParseError::InvalidFormat {
164            message: "BIC branch code (chars 9-11) must be alphanumeric".to_string(),
165        });
166    }
167
168    Ok(input.to_string())
169}
170
171/// Parse account number (max 34 characters)
172pub fn parse_account(input: &str) -> Result<String, ParseError> {
173    parse_max_length(input, 34, "Account")?;
174    parse_swift_chars(input, "Account")?;
175    Ok(input.to_string())
176}
177
178/// Get the number of decimal places for a currency according to ISO 4217
179///
180/// Returns the standard number of decimal places for the given currency code.
181/// Most currencies use 2 decimal places, but there are notable exceptions.
182///
183/// # Examples
184/// - JPY, KRW: 0 decimal places (yen, won are not subdivided)
185/// - BHD, KWD, OMR, TND: 3 decimal places (dinars subdivided into 1000 fils)
186/// - Most others: 2 decimal places (USD, EUR, GBP, etc.)
187///
188/// # Arguments
189/// * `currency` - ISO 4217 three-letter currency code
190///
191/// # Returns
192/// Number of decimal places (0, 2, or 3)
193pub fn get_currency_decimals(currency: &str) -> u8 {
194    match currency {
195        // Zero decimal currencies (not subdivided)
196        "BIF" | // Burundian Franc
197        "CLP" | // Chilean Peso
198        "DJF" | // Djiboutian Franc
199        "GNF" | // Guinean Franc
200        "ISK" | // Icelandic Króna
201        "JPY" | // Japanese Yen
202        "KMF" | // Comorian Franc
203        "KRW" | // South Korean Won
204        "PYG" | // Paraguayan Guaraní
205        "RWF" | // Rwandan Franc
206        "UGX" | // Ugandan Shilling
207        "UYI" | // Uruguay Peso en Unidades Indexadas
208        "VND" | // Vietnamese Đồng
209        "VUV" | // Vanuatu Vatu
210        "XAF" | // Central African CFA Franc
211        "XOF" | // West African CFA Franc
212        "XPF"   // CFP Franc
213        => 0,
214
215        // Three decimal currencies (subdivided into 1000)
216        "BHD" | // Bahraini Dinar
217        "IQD" | // Iraqi Dinar
218        "JOD" | // Jordanian Dinar
219        "KWD" | // Kuwaiti Dinar
220        "LYD" | // Libyan Dinar
221        "OMR" | // Omani Rial
222        "TND"   // Tunisian Dinar
223        => 3,
224
225        // Four decimal currencies (rare)
226        "CLF" | // Unidad de Fomento (Chile)
227        "UYW"   // Unidad Previsional (Uruguay)
228        => 4,
229
230        // Default: two decimal places (USD, EUR, GBP, CHF, CAD, AUD, etc.)
231        _ => 2,
232    }
233}
234
235/// Commodity currency codes that are not allowed in payment messages (C08 validation)
236const COMMODITY_CURRENCIES: &[&str] = &[
237    "XAU", // Gold
238    "XAG", // Silver
239    "XPD", // Palladium
240    "XPT", // Platinum
241];
242
243/// Validate that currency is not a commodity code (C08 validation)
244///
245/// SWIFT network validation rule C08 prohibits the use of commodity currency codes
246/// (XAU, XAG, XPD, XPT) in payment message amount fields.
247///
248/// # Arguments
249/// * `currency` - ISO 4217 currency code to validate
250///
251/// # Returns
252/// Ok(()) if valid, Err(ParseError) if commodity currency
253///
254/// # Errors
255/// Returns ParseError::InvalidFormat with C08 error if commodity currency detected
256pub fn validate_non_commodity_currency(currency: &str) -> Result<(), ParseError> {
257    if COMMODITY_CURRENCIES.contains(&currency) {
258        return Err(ParseError::InvalidFormat {
259            message: format!(
260                "Commodity currency code {} not allowed in payment messages (Error code: C08)",
261                currency
262            ),
263        });
264    }
265    Ok(())
266}
267
268/// Parse currency code (3 uppercase letters)
269pub fn parse_currency(input: &str) -> Result<String, ParseError> {
270    if input.len() != 3 {
271        return Err(ParseError::InvalidFormat {
272            message: format!(
273                "Currency code must be exactly 3 characters, found {}",
274                input.len()
275            ),
276        });
277    }
278
279    if !input.chars().all(|c| c.is_uppercase()) {
280        return Err(ParseError::InvalidFormat {
281            message: "Currency code must be uppercase letters".to_string(),
282        });
283    }
284
285    Ok(input.to_string())
286}
287
288/// Parse currency code with commodity validation (enforces C08 rule)
289///
290/// This is a stricter version of parse_currency that also validates against
291/// commodity currencies. Use this for amount fields (32A, 32B, 33B, 71F, 71G, etc.)
292///
293/// # Arguments
294/// * `input` - Currency code string to parse
295///
296/// # Returns
297/// Validated currency code string
298///
299/// # Errors
300/// Returns error if:
301/// - Not exactly 3 characters (T52)
302/// - Contains non-uppercase letters (T52)
303/// - Is a commodity currency code (C08)
304pub fn parse_currency_non_commodity(input: &str) -> Result<String, ParseError> {
305    let currency = parse_currency(input)?;
306    validate_non_commodity_currency(&currency)?;
307    Ok(currency)
308}
309
310/// Parse amount with optional decimal places
311pub fn parse_amount(input: &str) -> Result<f64, ParseError> {
312    // Remove any commas (European decimal separator handling)
313    let normalized = input.replace(',', ".");
314
315    normalized
316        .parse::<f64>()
317        .map_err(|e| ParseError::InvalidFormat {
318            message: format!("Invalid amount format: {}", e),
319        })
320}
321
322/// Validate amount decimal precision for a specific currency (C03 validation)
323///
324/// SWIFT network validation rule C03 requires that the number of decimal places
325/// in an amount must not exceed the maximum allowed for the currency.
326///
327/// # Arguments
328/// * `amount` - The amount value to validate
329/// * `currency` - ISO 4217 currency code
330///
331/// # Returns
332/// Ok(()) if decimal precision is valid, Err(ParseError) if exceeds limit
333///
334/// # Errors
335/// Returns ParseError::InvalidFormat with C03 error if decimal precision exceeded
336///
337/// # Examples
338/// ```
339/// use swift_mt_message::fields::swift_utils::validate_amount_decimals;
340///
341/// validate_amount_decimals(100.50, "USD").unwrap(); // Ok - 2 decimals allowed
342/// validate_amount_decimals(100.0, "JPY").unwrap();  // Ok - 0 decimals allowed
343/// assert!(validate_amount_decimals(100.50, "JPY").is_err()); // JPY allows 0 decimals only
344/// assert!(validate_amount_decimals(100.5055, "BHD").is_err()); // BHD allows 3 decimals max
345/// ```
346pub fn validate_amount_decimals(amount: f64, currency: &str) -> Result<(), ParseError> {
347    let max_decimals = get_currency_decimals(currency);
348
349    // Calculate actual decimal places in the amount
350    // Use string representation to avoid floating point precision issues
351    let amount_str = format!("{:.10}", amount); // Format with high precision
352    let decimal_places = if let Some(dot_pos) = amount_str.find('.') {
353        let after_dot = &amount_str[dot_pos + 1..];
354        // Count non-zero digits after decimal point
355        after_dot.trim_end_matches('0').len()
356    } else {
357        0
358    };
359
360    if decimal_places > max_decimals as usize {
361        return Err(ParseError::InvalidFormat {
362            message: format!(
363                "Amount has {} decimal places but currency {} allows maximum {} (Error code: C03)",
364                decimal_places, currency, max_decimals
365            ),
366        });
367    }
368
369    Ok(())
370}
371
372/// Parse amount with currency-specific decimal validation
373///
374/// This combines amount parsing with currency-specific decimal precision validation.
375/// Use this for amount fields where the currency is known (Field 32A, 32B, etc.)
376///
377/// # Arguments
378/// * `input` - Amount string to parse
379/// * `currency` - ISO 4217 currency code for decimal validation
380///
381/// # Returns
382/// Parsed amount as f64
383///
384/// # Errors
385/// Returns error if:
386/// - Amount format is invalid
387/// - Decimal precision exceeds currency limit (C03)
388pub fn parse_amount_with_currency(input: &str, currency: &str) -> Result<f64, ParseError> {
389    let amount = parse_amount(input)?;
390    validate_amount_decimals(amount, currency)?;
391    Ok(amount)
392}
393
394/// Format amount for SWIFT output with comma decimal separator
395///
396/// This function ensures SWIFT-compliant amount formatting:
397/// - Uses comma (,) as decimal separator instead of period (.)
398/// - Maintains proper decimal precision as specified
399/// - Keeps all decimal places including trailing zeros (per SWIFT spec)
400/// - Ensures at least one digit in the integer part
401///
402/// # Arguments
403/// * `amount` - The amount to format
404/// * `decimals` - Number of decimal places (typically 2 for most currencies)
405///
406/// # Returns
407/// SWIFT-formatted amount string with comma separator
408///
409/// # Examples
410/// ```
411/// use swift_mt_message::fields::swift_utils::format_swift_amount;
412///
413/// assert_eq!(format_swift_amount(1234.56, 2), "1234,56");
414/// assert_eq!(format_swift_amount(1000.00, 2), "1000,00");
415/// assert_eq!(format_swift_amount(1000.50, 2), "1000,50");
416/// ```
417pub fn format_swift_amount(amount: f64, decimals: usize) -> String {
418    let formatted = format!("{:.width$}", amount, width = decimals);
419    formatted.replace('.', ",")
420}
421
422/// Format amount for SWIFT output with currency-specific decimal precision
423///
424/// This is a currency-aware version of format_swift_amount that automatically
425/// determines the correct number of decimal places based on the currency code.
426///
427/// # Arguments
428/// * `amount` - The amount to format
429/// * `currency` - ISO 4217 currency code
430///
431/// # Returns
432/// SWIFT-formatted amount string with currency-appropriate precision
433///
434/// # Examples
435/// ```
436/// use swift_mt_message::fields::swift_utils::format_swift_amount_for_currency;
437///
438/// assert_eq!(format_swift_amount_for_currency(1234.56, "USD"), "1234,56");
439/// assert_eq!(format_swift_amount_for_currency(1500000.0, "JPY"), "1500000");
440/// assert_eq!(format_swift_amount_for_currency(123.456, "BHD"), "123,456");
441/// ```
442pub fn format_swift_amount_for_currency(amount: f64, currency: &str) -> String {
443    let decimals = get_currency_decimals(currency);
444    format_swift_amount(amount, decimals as usize)
445}
446
447/// Parse date in YYMMDD format
448pub fn parse_date_yymmdd(input: &str) -> Result<NaiveDate, ParseError> {
449    if input.len() != 6 {
450        return Err(ParseError::InvalidFormat {
451            message: format!(
452                "Date must be in YYMMDD format (6 digits), found {} characters",
453                input.len()
454            ),
455        });
456    }
457
458    let year = input[0..2]
459        .parse::<u32>()
460        .map_err(|_| ParseError::InvalidFormat {
461            message: "Invalid year in date".to_string(),
462        })?;
463    let month = input[2..4]
464        .parse::<u32>()
465        .map_err(|_| ParseError::InvalidFormat {
466            message: "Invalid month in date".to_string(),
467        })?;
468    let day = input[4..6]
469        .parse::<u32>()
470        .map_err(|_| ParseError::InvalidFormat {
471            message: "Invalid day in date".to_string(),
472        })?;
473
474    // Determine century: 00-49 -> 2000-2049, 50-99 -> 1950-1999
475    let full_year = if year <= 49 { 2000 + year } else { 1900 + year };
476
477    NaiveDate::from_ymd_opt(full_year as i32, month, day).ok_or_else(|| ParseError::InvalidFormat {
478        message: format!("Invalid date: {}/{}/{}", full_year, month, day),
479    })
480}
481
482/// Parse date in YYYYMMDD format
483pub fn parse_date_yyyymmdd(input: &str) -> Result<NaiveDate, ParseError> {
484    if input.len() != 8 {
485        return Err(ParseError::InvalidFormat {
486            message: format!(
487                "Date must be in YYYYMMDD format (8 digits), found {} characters",
488                input.len()
489            ),
490        });
491    }
492
493    let year = input[0..4]
494        .parse::<i32>()
495        .map_err(|_| ParseError::InvalidFormat {
496            message: "Invalid year in date".to_string(),
497        })?;
498    let month = input[4..6]
499        .parse::<u32>()
500        .map_err(|_| ParseError::InvalidFormat {
501            message: "Invalid month in date".to_string(),
502        })?;
503    let day = input[6..8]
504        .parse::<u32>()
505        .map_err(|_| ParseError::InvalidFormat {
506            message: "Invalid day in date".to_string(),
507        })?;
508
509    NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| ParseError::InvalidFormat {
510        message: format!("Invalid date: {}/{}/{}", year, month, day),
511    })
512}
513
514/// Parse time in HHMM format
515pub fn parse_time_hhmm(input: &str) -> Result<NaiveTime, ParseError> {
516    if input.len() != 4 {
517        return Err(ParseError::InvalidFormat {
518            message: format!(
519                "Time must be in HHMM format (4 digits), found {} characters",
520                input.len()
521            ),
522        });
523    }
524
525    let hour = input[0..2]
526        .parse::<u32>()
527        .map_err(|_| ParseError::InvalidFormat {
528            message: "Invalid hour in time".to_string(),
529        })?;
530    let minute = input[2..4]
531        .parse::<u32>()
532        .map_err(|_| ParseError::InvalidFormat {
533            message: "Invalid minute in time".to_string(),
534        })?;
535
536    NaiveTime::from_hms_opt(hour, minute, 0).ok_or_else(|| ParseError::InvalidFormat {
537        message: format!("Invalid time: {}:{}", hour, minute),
538    })
539}
540
541/// Parse datetime in YYMMDDHHMM format
542pub fn parse_datetime_yymmddhhmm(input: &str) -> Result<NaiveDateTime, ParseError> {
543    if input.len() != 10 {
544        return Err(ParseError::InvalidFormat {
545            message: format!(
546                "DateTime must be in YYMMDDHHMM format (10 digits), found {} characters",
547                input.len()
548            ),
549        });
550    }
551
552    let date = parse_date_yymmdd(&input[0..6])?;
553    let time = parse_time_hhmm(&input[6..10])?;
554
555    Ok(NaiveDateTime::new(date, time))
556}
557
558/// Parse reference (16x - alphanumeric, max 16 chars)
559pub fn parse_reference(input: &str) -> Result<String, ParseError> {
560    parse_max_length(input, 16, "Reference")?;
561    parse_swift_chars(input, "Reference")?;
562    Ok(input.to_string())
563}
564
565/// Split input at first occurrence of delimiter
566pub fn split_at_first(input: &str, delimiter: char) -> (String, Option<String>) {
567    if let Some(pos) = input.find(delimiter) {
568        let (first, rest) = input.split_at(pos);
569        let rest = &rest[1..]; // Skip the delimiter
570        (
571            first.to_string(),
572            if rest.is_empty() {
573                None
574            } else {
575                Some(rest.to_string())
576            },
577        )
578    } else {
579        (input.to_string(), None)
580    }
581}
582
583/// Split input at first newline
584pub fn split_at_newline(input: &str) -> (String, Option<String>) {
585    if let Some(pos) = input.find('\n') {
586        let (first, rest) = input.split_at(pos);
587        let rest = &rest[1..]; // Skip the newline
588        (
589            first.to_string(),
590            if rest.is_empty() {
591                None
592            } else {
593                Some(rest.to_string())
594            },
595        )
596    } else {
597        (input.to_string(), None)
598    }
599}
600
601/// Clean and normalize text (remove extra whitespace, normalize line endings)
602pub fn normalize_text(input: &str) -> String {
603    input
604        .lines()
605        .map(|line| line.trim())
606        .filter(|line| !line.is_empty())
607        .collect::<Vec<_>>()
608        .join("\n")
609}
610
611/// Validate IBAN format
612pub fn validate_iban(iban: &str) -> Result<(), ParseError> {
613    // Basic IBAN validation (simplified)
614    if iban.len() < 15 || iban.len() > 34 {
615        return Err(ParseError::InvalidFormat {
616            message: format!(
617                "IBAN must be between 15 and 34 characters, found {}",
618                iban.len()
619            ),
620        });
621    }
622
623    // First two characters must be country code (letters)
624    if !iban[0..2].chars().all(|c| c.is_uppercase()) {
625        return Err(ParseError::InvalidFormat {
626            message: "IBAN country code must be uppercase letters".to_string(),
627        });
628    }
629
630    // Next two characters must be check digits
631    if !iban[2..4].chars().all(|c| c.is_numeric()) {
632        return Err(ParseError::InvalidFormat {
633            message: "IBAN check digits must be numeric".to_string(),
634        });
635    }
636
637    Ok(())
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use chrono::Datelike;
644
645    #[test]
646    fn test_parse_exact_length() {
647        assert!(parse_exact_length("ABC", 3, "Test").is_ok());
648        assert!(parse_exact_length("AB", 3, "Test").is_err());
649        assert!(parse_exact_length("ABCD", 3, "Test").is_err());
650    }
651
652    #[test]
653    fn test_parse_bic() {
654        assert!(parse_bic("DEUTDEFF").is_ok());
655        assert!(parse_bic("DEUTDEFFXXX").is_ok());
656        assert!(parse_bic("DEUT").is_err()); // Too short
657        assert!(parse_bic("DEUTDEFFXX").is_err()); // Wrong length
658        assert!(parse_bic("1234DEFF").is_err()); // Invalid bank code
659    }
660
661    #[test]
662    fn test_parse_currency() {
663        assert!(parse_currency("USD").is_ok());
664        assert!(parse_currency("EUR").is_ok());
665        assert!(parse_currency("US").is_err()); // Too short
666        assert!(parse_currency("usd").is_err()); // Not uppercase
667    }
668
669    #[test]
670    fn test_parse_date_yymmdd() {
671        let date = parse_date_yymmdd("231225").unwrap();
672        assert_eq!(date.year(), 2023);
673        assert_eq!(date.month(), 12);
674        assert_eq!(date.day(), 25);
675
676        let date = parse_date_yymmdd("991231").unwrap();
677        assert_eq!(date.year(), 1999);
678    }
679
680    #[test]
681    fn test_parse_amount() {
682        assert!(parse_amount("1234.56").is_ok());
683        assert!(parse_amount("1234,56").is_ok()); // European format
684        assert!(parse_amount("1234").is_ok());
685        assert!(parse_amount("abc").is_err());
686    }
687
688    #[test]
689    fn test_format_swift_amount() {
690        // Test standard 2 decimal formatting
691        assert_eq!(format_swift_amount(1234.56, 2), "1234,56");
692        assert_eq!(format_swift_amount(1000.00, 2), "1000,00");
693        assert_eq!(format_swift_amount(1000.50, 2), "1000,50");
694
695        // Test with trailing zeros (per SWIFT spec, keep all decimals)
696        assert_eq!(format_swift_amount(5000.00, 2), "5000,00");
697        assert_eq!(format_swift_amount(2500.00, 2), "2500,00");
698
699        // Test with single decimal
700        assert_eq!(format_swift_amount(250.75, 2), "250,75");
701        assert_eq!(format_swift_amount(99.99, 2), "99,99");
702
703        // Test large amounts
704        assert_eq!(format_swift_amount(1000000.0, 2), "1000000,00");
705        assert_eq!(format_swift_amount(1234567.89, 2), "1234567,89");
706
707        // Test zero decimals (for currencies like JPY)
708        assert_eq!(format_swift_amount(1500000.0, 0), "1500000");
709
710        // Test three decimals (for currencies like BHD)
711        assert_eq!(format_swift_amount(123.456, 3), "123,456");
712    }
713
714    #[test]
715    fn test_split_at_first() {
716        let (first, rest) = split_at_first("ABC/DEF/GHI", '/');
717        assert_eq!(first, "ABC");
718        assert_eq!(rest, Some("DEF/GHI".to_string()));
719
720        let (first, rest) = split_at_first("ABCDEF", '/');
721        assert_eq!(first, "ABCDEF");
722        assert_eq!(rest, None);
723    }
724
725    #[test]
726    fn test_validate_iban() {
727        assert!(validate_iban("DE89370400440532013000").is_ok());
728        assert!(validate_iban("GB82WEST12345698765432").is_ok());
729        assert!(validate_iban("DE89").is_err()); // Too short
730        assert!(validate_iban("1234567890123456").is_err()); // Invalid country code
731    }
732
733    #[test]
734    fn test_get_currency_decimals() {
735        // Test zero decimal currencies
736        assert_eq!(get_currency_decimals("JPY"), 0);
737        assert_eq!(get_currency_decimals("KRW"), 0);
738        assert_eq!(get_currency_decimals("VND"), 0);
739        assert_eq!(get_currency_decimals("CLP"), 0);
740
741        // Test two decimal currencies (default)
742        assert_eq!(get_currency_decimals("USD"), 2);
743        assert_eq!(get_currency_decimals("EUR"), 2);
744        assert_eq!(get_currency_decimals("GBP"), 2);
745        assert_eq!(get_currency_decimals("CHF"), 2);
746
747        // Test three decimal currencies
748        assert_eq!(get_currency_decimals("BHD"), 3);
749        assert_eq!(get_currency_decimals("KWD"), 3);
750        assert_eq!(get_currency_decimals("OMR"), 3);
751        assert_eq!(get_currency_decimals("TND"), 3);
752
753        // Test four decimal currencies
754        assert_eq!(get_currency_decimals("CLF"), 4);
755
756        // Test unknown currency (defaults to 2)
757        assert_eq!(get_currency_decimals("XXX"), 2);
758    }
759
760    #[test]
761    fn test_validate_non_commodity_currency() {
762        // Valid non-commodity currencies
763        assert!(validate_non_commodity_currency("USD").is_ok());
764        assert!(validate_non_commodity_currency("EUR").is_ok());
765        assert!(validate_non_commodity_currency("JPY").is_ok());
766
767        // Invalid commodity currencies (C08)
768        assert!(validate_non_commodity_currency("XAU").is_err()); // Gold
769        assert!(validate_non_commodity_currency("XAG").is_err()); // Silver
770        assert!(validate_non_commodity_currency("XPD").is_err()); // Palladium
771        assert!(validate_non_commodity_currency("XPT").is_err()); // Platinum
772
773        // Verify error message contains C08
774        let err = validate_non_commodity_currency("XAU").unwrap_err();
775        if let ParseError::InvalidFormat { message } = err {
776            assert!(message.contains("C08"));
777            assert!(message.contains("XAU"));
778        } else {
779            panic!("Expected InvalidFormat error");
780        }
781    }
782
783    #[test]
784    fn test_parse_currency_non_commodity() {
785        // Valid non-commodity currencies
786        assert_eq!(parse_currency_non_commodity("USD").unwrap(), "USD");
787        assert_eq!(parse_currency_non_commodity("EUR").unwrap(), "EUR");
788        assert_eq!(parse_currency_non_commodity("JPY").unwrap(), "JPY");
789
790        // Invalid commodity currencies
791        assert!(parse_currency_non_commodity("XAU").is_err());
792        assert!(parse_currency_non_commodity("XAG").is_err());
793
794        // Invalid format
795        assert!(parse_currency_non_commodity("US").is_err()); // Too short
796        assert!(parse_currency_non_commodity("usd").is_err()); // Lowercase
797    }
798
799    #[test]
800    fn test_validate_amount_decimals() {
801        // USD (2 decimals allowed)
802        assert!(validate_amount_decimals(100.0, "USD").is_ok());
803        assert!(validate_amount_decimals(100.5, "USD").is_ok());
804        assert!(validate_amount_decimals(100.50, "USD").is_ok());
805        assert!(validate_amount_decimals(100.505, "USD").is_err()); // 3 decimals
806
807        // JPY (0 decimals allowed)
808        assert!(validate_amount_decimals(100.0, "JPY").is_ok());
809        assert!(validate_amount_decimals(1500000.0, "JPY").is_ok());
810        assert!(validate_amount_decimals(100.5, "JPY").is_err()); // Has decimals
811        assert!(validate_amount_decimals(100.50, "JPY").is_err()); // Has decimals
812
813        // BHD (3 decimals allowed)
814        assert!(validate_amount_decimals(100.0, "BHD").is_ok());
815        assert!(validate_amount_decimals(100.5, "BHD").is_ok());
816        assert!(validate_amount_decimals(100.505, "BHD").is_ok());
817        assert!(validate_amount_decimals(100.5055, "BHD").is_err()); // 4 decimals
818
819        // Verify error message contains C03
820        let err = validate_amount_decimals(100.505, "USD").unwrap_err();
821        if let ParseError::InvalidFormat { message } = err {
822            assert!(message.contains("C03"));
823            assert!(message.contains("USD"));
824        } else {
825            panic!("Expected InvalidFormat error");
826        }
827    }
828
829    #[test]
830    fn test_parse_amount_with_currency() {
831        // Valid amounts with correct decimals
832        assert_eq!(parse_amount_with_currency("100.50", "USD").unwrap(), 100.50);
833        assert_eq!(
834            parse_amount_with_currency("1500000", "JPY").unwrap(),
835            1500000.0
836        );
837        assert_eq!(
838            parse_amount_with_currency("100.505", "BHD").unwrap(),
839            100.505
840        );
841
842        // European format (comma separator)
843        assert_eq!(parse_amount_with_currency("100,50", "EUR").unwrap(), 100.50);
844
845        // Invalid: too many decimals
846        assert!(parse_amount_with_currency("100.505", "USD").is_err());
847        assert!(parse_amount_with_currency("100.5", "JPY").is_err());
848        assert!(parse_amount_with_currency("100.5055", "BHD").is_err());
849
850        // Invalid format
851        assert!(parse_amount_with_currency("abc", "USD").is_err());
852    }
853
854    #[test]
855    fn test_format_swift_amount_for_currency() {
856        // USD (2 decimals)
857        assert_eq!(format_swift_amount_for_currency(1234.56, "USD"), "1234,56");
858        assert_eq!(format_swift_amount_for_currency(1000.00, "USD"), "1000,00");
859        assert_eq!(format_swift_amount_for_currency(1000.50, "USD"), "1000,50");
860
861        // JPY (0 decimals)
862        assert_eq!(
863            format_swift_amount_for_currency(1500000.0, "JPY"),
864            "1500000"
865        );
866        assert_eq!(format_swift_amount_for_currency(1234.0, "JPY"), "1234");
867
868        // BHD (3 decimals)
869        assert_eq!(format_swift_amount_for_currency(123.456, "BHD"), "123,456");
870        assert_eq!(format_swift_amount_for_currency(100.5, "BHD"), "100,500");
871        assert_eq!(format_swift_amount_for_currency(100.0, "BHD"), "100,000");
872
873        // EUR (2 decimals)
874        assert_eq!(format_swift_amount_for_currency(5000.00, "EUR"), "5000,00");
875        assert_eq!(format_swift_amount_for_currency(250.75, "EUR"), "250,75");
876    }
877}