swift_mt_message/fields/
field36.rs

1use crate::{SwiftField, ValidationError, ValidationResult};
2use serde::{Deserialize, Serialize};
3
4/// # Field 36: Exchange Rate
5///
6/// ## Overview
7/// Field 36 specifies the exchange rate to be applied between the currencies in Field 33B
8/// (Instructed Currency and Amount) and Field 32A (Value Date/Currency/Amount) in SWIFT
9/// payment messages. This field is only present when the currency codes in these two fields
10/// differ, indicating a foreign exchange transaction is required.
11///
12/// ## Format Specification
13/// **Format**: `12d`
14/// - **12d**: Up to 12 digits including decimal separator
15/// - **Decimal separator**: Comma (,) as per SWIFT convention
16/// - **Precision**: Up to 6 decimal places (trailing zeros removed)
17/// - **Character set**: Digits 0-9 and comma (,)
18/// - **Validation**: Must be positive, non-zero value
19///
20/// ## Usage Context
21/// Field 36 is commonly used in:
22/// - **MT103**: Single Customer Credit Transfer (when currency conversion required)
23/// - **MT202**: General Financial Institution Transfer (FX transactions)
24/// - **MT202COV**: Cover for customer credit transfer with FX
25/// - **MT200**: Financial Institution Transfer (simple FX)
26///
27/// ### Business Applications
28/// - **Currency conversion**: Applying agreed exchange rates to international payments
29/// - **FX transparency**: Providing clear rate information to receiving institutions
30/// - **Reconciliation**: Enabling accurate settlement calculations
31/// - **Compliance**: Meeting regulatory requirements for FX rate disclosure
32/// - **Audit trails**: Maintaining records of applied exchange rates
33///
34/// ## Examples
35/// ```text
36/// :36:1,2345
37/// └─── USD/EUR rate of 1.2345 (1 USD = 1.2345 EUR)
38///
39/// :36:0,8765
40/// └─── EUR/USD rate of 0.8765 (1 EUR = 0.8765 USD)
41///
42/// :36:110,5
43/// └─── USD/JPY rate of 110.5 (1 USD = 110.5 JPY)
44///
45/// :36:1
46/// └─── 1:1 rate (special cases, same currency family)
47/// ```
48///
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct Field36 {
51    /// Exchange rate value
52    pub rate: f64,
53    /// Raw rate string as received (preserves original formatting)
54    pub raw_rate: String,
55}
56
57impl Field36 {
58    /// Create a new Field36 with validation
59    pub fn new(rate: f64) -> Result<Self, crate::ParseError> {
60        // Validate rate
61        if rate <= 0.0 {
62            return Err(crate::ParseError::InvalidFieldFormat {
63                field_tag: "36".to_string(),
64                message: "Exchange rate must be positive".to_string(),
65            });
66        }
67
68        let raw_rate = Self::format_rate(rate);
69
70        // Check if rate string is reasonable length (up to 12 digits)
71        let normalized_rate = raw_rate.replace(',', ".");
72        if normalized_rate.len() > 12 {
73            return Err(crate::ParseError::InvalidFieldFormat {
74                field_tag: "36".to_string(),
75                message: "Exchange rate too long (max 12 digits)".to_string(),
76            });
77        }
78
79        Ok(Field36 { rate, raw_rate })
80    }
81
82    /// Create from raw rate string
83    pub fn from_raw(raw_rate: impl Into<String>) -> Result<Self, crate::ParseError> {
84        let raw_rate = raw_rate.into();
85        let rate = Self::parse_rate(&raw_rate)?;
86
87        Ok(Field36 {
88            rate,
89            raw_rate: raw_rate.to_string(),
90        })
91    }
92
93    /// Get the exchange rate value
94    pub fn rate(&self) -> f64 {
95        self.rate
96    }
97
98    /// Get the raw rate string
99    pub fn raw_rate(&self) -> &str {
100        &self.raw_rate
101    }
102
103    /// Format rate for SWIFT output (preserving decimal places, using comma)
104    pub fn format_rate(rate: f64) -> String {
105        // Format with up to 6 decimal places, remove trailing zeros
106        let formatted = format!("{:.6}", rate);
107        let trimmed = formatted.trim_end_matches('0').trim_end_matches('.');
108        trimmed.replace('.', ",")
109    }
110
111    /// Parse rate from string (handles both comma and dot as decimal separator)
112    fn parse_rate(rate_str: &str) -> Result<f64, crate::ParseError> {
113        let normalized_rate = rate_str.replace(',', ".");
114
115        let rate =
116            normalized_rate
117                .parse::<f64>()
118                .map_err(|_| crate::ParseError::InvalidFieldFormat {
119                    field_tag: "36".to_string(),
120                    message: "Invalid exchange rate format".to_string(),
121                })?;
122
123        if rate <= 0.0 {
124            return Err(crate::ParseError::InvalidFieldFormat {
125                field_tag: "36".to_string(),
126                message: "Exchange rate must be positive".to_string(),
127            });
128        }
129
130        Ok(rate)
131    }
132
133    /// Check if this is a reasonable exchange rate (between 0.0001 and 10000)
134    pub fn is_reasonable_rate(&self) -> bool {
135        self.rate >= 0.0001 && self.rate <= 10000.0
136    }
137
138    /// Get human-readable description
139    pub fn description(&self) -> String {
140        format!("Exchange Rate: {}", self.raw_rate)
141    }
142}
143
144impl SwiftField for Field36 {
145    fn parse(value: &str) -> Result<Self, crate::ParseError> {
146        let content = if let Some(stripped) = value.strip_prefix(":36:") {
147            stripped // Remove ":36:" prefix
148        } else if let Some(stripped) = value.strip_prefix("36:") {
149            stripped // Remove "36:" prefix
150        } else {
151            value
152        };
153
154        let content = content.trim();
155
156        if content.is_empty() {
157            return Err(crate::ParseError::InvalidFieldFormat {
158                field_tag: "36".to_string(),
159                message: "Exchange rate cannot be empty".to_string(),
160            });
161        }
162
163        // Check length constraint
164        if content.len() > 12 {
165            return Err(crate::ParseError::InvalidFieldFormat {
166                field_tag: "36".to_string(),
167                message: "Exchange rate too long (max 12 characters)".to_string(),
168            });
169        }
170
171        // Validate characters (digits, comma, dot only)
172        if !content
173            .chars()
174            .all(|c| c.is_ascii_digit() || c == ',' || c == '.')
175        {
176            return Err(crate::ParseError::InvalidFieldFormat {
177                field_tag: "36".to_string(),
178                message: "Exchange rate must contain only digits and decimal separator".to_string(),
179            });
180        }
181
182        let rate = Self::parse_rate(content)?;
183
184        Ok(Field36 {
185            rate,
186            raw_rate: content.to_string(),
187        })
188    }
189
190    fn to_swift_string(&self) -> String {
191        format!(":36:{}", self.raw_rate)
192    }
193
194    fn validate(&self) -> ValidationResult {
195        let mut errors = Vec::new();
196        let mut warnings = Vec::new();
197
198        // Validate rate value
199        if self.rate <= 0.0 {
200            errors.push(ValidationError::ValueValidation {
201                field_tag: "36".to_string(),
202                message: "Exchange rate must be positive".to_string(),
203            });
204        }
205
206        // Validate raw rate format
207        if self.raw_rate.is_empty() {
208            errors.push(ValidationError::ValueValidation {
209                field_tag: "36".to_string(),
210                message: "Exchange rate cannot be empty".to_string(),
211            });
212        }
213
214        // Check length constraint
215        if self.raw_rate.len() > 12 {
216            errors.push(ValidationError::LengthValidation {
217                field_tag: "36".to_string(),
218                expected: "max 12 characters".to_string(),
219                actual: self.raw_rate.len(),
220            });
221        }
222
223        // Warning for unreasonable rates
224        if !self.is_reasonable_rate() {
225            warnings.push(format!(
226                "Exchange rate {} may be unreasonable (typical range: 0.0001 to 10000)",
227                self.rate
228            ));
229        }
230
231        ValidationResult {
232            is_valid: errors.is_empty(),
233            errors,
234            warnings,
235        }
236    }
237
238    fn format_spec() -> &'static str {
239        "12d"
240    }
241}
242
243impl std::fmt::Display for Field36 {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        write!(f, "{}", self.raw_rate)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_field36_creation() {
255        let field = Field36::new(1.2345).unwrap();
256        assert_eq!(field.rate(), 1.2345);
257        assert_eq!(field.raw_rate(), "1,2345");
258    }
259
260    #[test]
261    fn test_field36_from_raw() {
262        let field = Field36::from_raw("0,8567").unwrap();
263        assert_eq!(field.rate(), 0.8567);
264        assert_eq!(field.raw_rate(), "0,8567");
265    }
266
267    #[test]
268    fn test_field36_parse() {
269        let field = Field36::parse("1,5678").unwrap();
270        assert_eq!(field.rate(), 1.5678);
271        assert_eq!(field.raw_rate(), "1,5678");
272    }
273
274    #[test]
275    fn test_field36_parse_with_prefix() {
276        let field = Field36::parse(":36:2,3456").unwrap();
277        assert_eq!(field.rate(), 2.3456);
278        assert_eq!(field.raw_rate(), "2,3456");
279    }
280
281    #[test]
282    fn test_field36_parse_dot_decimal() {
283        let field = Field36::parse("1.2345").unwrap();
284        assert_eq!(field.rate(), 1.2345);
285        assert_eq!(field.raw_rate(), "1.2345");
286    }
287
288    #[test]
289    fn test_field36_to_swift_string() {
290        let field = Field36::new(0.9876).unwrap();
291        assert_eq!(field.to_swift_string(), ":36:0,9876");
292    }
293
294    #[test]
295    fn test_field36_zero_rate() {
296        let result = Field36::new(0.0);
297        assert!(result.is_err());
298    }
299
300    #[test]
301    fn test_field36_negative_rate() {
302        let result = Field36::new(-1.5);
303        assert!(result.is_err());
304    }
305
306    #[test]
307    fn test_field36_too_long() {
308        let result = Field36::parse("123456789012345"); // 15 characters
309        assert!(result.is_err());
310    }
311
312    #[test]
313    fn test_field36_invalid_characters() {
314        let result = Field36::parse("1.23a45");
315        assert!(result.is_err());
316
317        let result = Field36::parse("1,23-45");
318        assert!(result.is_err());
319    }
320
321    #[test]
322    fn test_field36_empty() {
323        let result = Field36::parse("");
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn test_field36_validation() {
329        let field = Field36::new(1.5).unwrap();
330        let validation = field.validate();
331        assert!(validation.is_valid);
332        assert!(validation.errors.is_empty());
333    }
334
335    #[test]
336    fn test_field36_unreasonable_rate_warning() {
337        let field = Field36::new(50000.0).unwrap();
338        let validation = field.validate();
339        assert!(validation.is_valid); // Still valid, just warning
340        assert!(!validation.warnings.is_empty());
341    }
342
343    #[test]
344    fn test_field36_is_reasonable_rate() {
345        let field1 = Field36::new(1.5).unwrap();
346        assert!(field1.is_reasonable_rate());
347
348        let field2 = Field36::new(0.00001).unwrap();
349        assert!(!field2.is_reasonable_rate());
350
351        let field3 = Field36::new(50000.0).unwrap();
352        assert!(!field3.is_reasonable_rate());
353    }
354
355    #[test]
356    fn test_field36_display() {
357        let field = Field36::new(1.2345).unwrap();
358        assert_eq!(format!("{}", field), "1,2345");
359    }
360
361    #[test]
362    fn test_field36_description() {
363        let field = Field36::new(0.8765).unwrap();
364        assert_eq!(field.description(), "Exchange Rate: 0,8765");
365    }
366
367    #[test]
368    fn test_field36_format_rate() {
369        assert_eq!(Field36::format_rate(1.0), "1");
370        assert_eq!(Field36::format_rate(1.5), "1,5");
371        assert_eq!(Field36::format_rate(1.123456), "1,123456");
372        assert_eq!(Field36::format_rate(1.100000), "1,1");
373    }
374}