swift_mt_message/fields/
field71f.rs

1use crate::{SwiftField, ValidationError, ValidationResult};
2use serde::{Deserialize, Serialize};
3
4/// # Field 71F: Sender's Charges
5///
6/// ## Overview
7/// Field 71F specifies the charges borne by the sender in SWIFT payment messages. This field
8/// contains the currency and amount of charges that the ordering customer or sending institution
9/// pays for processing the payment transaction. These charges are separate from the main payment
10/// amount and provide transparency in fee allocation, particularly important for correspondent
11/// banking arrangements and regulatory compliance.
12///
13/// ## Format Specification
14/// **Format**: `3!a15d`
15/// - **3!a**: Currency code (3 alphabetic characters, ISO 4217)
16/// - **15d**: Amount with up to 15 digits (including decimal places)
17/// - **Decimal separator**: Comma (,) as per SWIFT standards
18/// - **Amount format**: No thousands separators, up to 2 decimal places
19///
20/// ## Structure
21/// ```text
22/// USD25,50
23/// │││└──┘
24/// │││  └─ Amount (25.50)
25/// └┴┴─── Currency (USD)
26/// ```
27///
28/// ## Field Components
29/// - **Currency Code**: ISO 4217 three-letter currency code
30///   - Must be valid and recognized currency
31///   - Alphabetic characters only
32///   - Case-insensitive but normalized to uppercase
33/// - **Charge Amount**: Monetary amount of sender's charges
34///   - Maximum 15 digits including decimal places
35///   - Comma as decimal separator
36///   - Non-negative values only
37///
38/// ## Usage Context
39/// Field 71F is used in:
40/// - **MT103**: Single Customer Credit Transfer
41/// - **MT200**: Financial Institution Transfer
42/// - **MT202**: General Financial Institution Transfer
43/// - **MT202COV**: Cover for customer credit transfer
44/// - **MT205**: Financial Institution Transfer for its own account
45///
46/// ### Business Applications
47/// - **Charge transparency**: Detailed fee disclosure
48/// - **Cost accounting**: Accurate charge tracking
49/// - **Correspondent banking**: Fee settlement between banks
50/// - **Regulatory compliance**: Charge reporting requirements
51/// - **Customer billing**: Separate charge invoicing
52/// - **Audit trails**: Complete transaction cost records
53///
54/// ## Examples
55/// ```text
56/// :71F:USD25,50
57/// └─── USD 25.50 in sender's charges
58///
59/// :71F:EUR15,00
60/// └─── EUR 15.00 in processing fees
61///
62/// :71F:GBP10,75
63/// └─── GBP 10.75 in correspondent charges
64///
65/// :71F:CHF50,00
66/// └─── CHF 50.00 in urgent transfer fees
67///
68/// :71F:JPY2500,00
69/// └─── JPY 2,500.00 in international charges
70/// ```
71///
72/// ## Charge Types
73/// - **Processing fees**: Basic transaction processing charges
74/// - **Correspondent charges**: Fees for correspondent bank services
75/// - **Urgent transfer fees**: Premium charges for same-day processing
76/// - **Regulatory fees**: Charges for compliance and reporting
77/// - **Network fees**: SWIFT network usage charges
78/// - **Investigation fees**: Charges for payment inquiries or investigations
79///
80/// ## Currency Guidelines
81/// - **ISO 4217 compliance**: Must use standard currency codes
82/// - **Active currencies**: Should use currently active currency codes
83/// - **Major currencies**: USD, EUR, GBP, JPY, CHF commonly used
84/// - **Local currencies**: Domestic currency for local charges
85/// - **Consistency**: Should align with payment currency where appropriate
86///
87/// ## Amount Formatting
88/// - **Decimal places**: Typically 2 decimal places for most currencies
89/// - **Japanese Yen**: Usually no decimal places (whole numbers)
90/// - **Precision**: Up to 15 total digits including decimals
91/// - **Range**: Must be non-negative (zero or positive)
92/// - **Separator**: Comma (,) for decimal separation
93///
94/// ## Validation Rules
95/// 1. **Currency format**: Must be exactly 3 alphabetic characters
96/// 2. **Currency validity**: Must be valid ISO 4217 currency code
97/// 3. **Amount format**: Must follow SWIFT decimal format with comma
98/// 4. **Amount range**: Must be non-negative
99/// 5. **Length limits**: Total field length within SWIFT limits
100/// 6. **Character validation**: Only allowed characters in amount
101///
102/// ## Network Validated Rules (SWIFT Standards)
103/// - Currency must be valid ISO 4217 code (Error: T52)
104/// - Amount must be properly formatted (Error: T40)
105/// - Amount cannot be negative (Error: T13)
106/// - Decimal separator must be comma (Error: T41)
107/// - Maximum 15 digits in amount (Error: T50)
108/// - Currency must be alphabetic only (Error: T15)
109/// - Field format must comply with specification (Error: T26)
110///
111
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
113pub struct Field71F {
114    /// Currency code (3 letters, ISO 4217)
115    pub currency: String,
116    /// Charge amount
117    pub amount: f64,
118    /// Raw amount string as received (preserves original formatting)
119    pub raw_amount: String,
120}
121
122impl Field71F {
123    /// Create a new Field71F with validation
124    pub fn new(currency: impl Into<String>, amount: f64) -> Result<Self, crate::ParseError> {
125        let currency = currency.into().to_uppercase();
126
127        // Validate currency code
128        if currency.len() != 3 {
129            return Err(crate::ParseError::InvalidFieldFormat {
130                field_tag: "71F".to_string(),
131                message: "Currency code must be exactly 3 characters".to_string(),
132            });
133        }
134
135        if !currency.chars().all(|c| c.is_alphabetic() && c.is_ascii()) {
136            return Err(crate::ParseError::InvalidFieldFormat {
137                field_tag: "71F".to_string(),
138                message: "Currency code must contain only alphabetic characters".to_string(),
139            });
140        }
141
142        // Validate amount
143        if amount < 0.0 {
144            return Err(crate::ParseError::InvalidFieldFormat {
145                field_tag: "71F".to_string(),
146                message: "Charge amount cannot be negative".to_string(),
147            });
148        }
149
150        let raw_amount = Self::format_amount(amount);
151
152        Ok(Field71F {
153            currency,
154            amount,
155            raw_amount,
156        })
157    }
158
159    /// Create from raw amount string
160    pub fn from_raw(
161        currency: impl Into<String>,
162        raw_amount: impl Into<String>,
163    ) -> Result<Self, crate::ParseError> {
164        let currency = currency.into().to_uppercase();
165        let raw_amount = raw_amount.into();
166
167        let amount = Self::parse_amount(&raw_amount)?;
168
169        Ok(Field71F {
170            currency,
171            amount,
172            raw_amount: raw_amount.to_string(),
173        })
174    }
175
176    /// Get the currency code
177    pub fn currency(&self) -> &str {
178        &self.currency
179    }
180
181    /// Get the charge amount
182    pub fn amount(&self) -> f64 {
183        self.amount
184    }
185
186    /// Get the raw amount string
187    pub fn raw_amount(&self) -> &str {
188        &self.raw_amount
189    }
190
191    /// Format amount for SWIFT output (with comma as decimal separator)
192    pub fn format_amount(amount: f64) -> String {
193        format!("{:.2}", amount).replace('.', ",")
194    }
195
196    /// Parse amount from string (handles both comma and dot as decimal separator)
197    fn parse_amount(amount_str: &str) -> Result<f64, crate::ParseError> {
198        let normalized_amount = amount_str.replace(',', ".");
199
200        normalized_amount
201            .parse::<f64>()
202            .map_err(|_| crate::ParseError::InvalidFieldFormat {
203                field_tag: "71F".to_string(),
204                message: "Invalid charge amount format".to_string(),
205            })
206    }
207
208    /// Get human-readable description
209    pub fn description(&self) -> String {
210        format!("Sender's Charges: {} {}", self.currency, self.raw_amount)
211    }
212}
213
214impl SwiftField for Field71F {
215    fn parse(value: &str) -> Result<Self, crate::ParseError> {
216        let content = if let Some(stripped) = value.strip_prefix(":71F:") {
217            stripped // Remove ":71F:" prefix
218        } else if let Some(stripped) = value.strip_prefix("71F:") {
219            stripped // Remove "71F:" prefix
220        } else {
221            value
222        };
223
224        let content = content.trim();
225
226        if content.len() < 4 {
227            return Err(crate::ParseError::InvalidFieldFormat {
228                field_tag: "71F".to_string(),
229                message: "Field content too short (minimum 4 characters: CCCAMOUNT)".to_string(),
230            });
231        }
232
233        // Parse components: first 3 characters are currency, rest is amount
234        let currency_str = &content[0..3];
235        let amount_str = &content[3..];
236
237        let currency = currency_str.to_uppercase();
238
239        // Validate currency
240        if !currency.chars().all(|c| c.is_alphabetic() && c.is_ascii()) {
241            return Err(crate::ParseError::InvalidFieldFormat {
242                field_tag: "71F".to_string(),
243                message: "Currency code must contain only alphabetic characters".to_string(),
244            });
245        }
246
247        let amount = Self::parse_amount(amount_str)?;
248
249        if amount < 0.0 {
250            return Err(crate::ParseError::InvalidFieldFormat {
251                field_tag: "71F".to_string(),
252                message: "Charge amount cannot be negative".to_string(),
253            });
254        }
255
256        Ok(Field71F {
257            currency,
258            amount,
259            raw_amount: amount_str.to_string(),
260        })
261    }
262
263    fn to_swift_string(&self) -> String {
264        format!(":71F:{}{}", self.currency, self.raw_amount)
265    }
266
267    fn validate(&self) -> ValidationResult {
268        let mut errors = Vec::new();
269
270        // Validate currency code
271        if self.currency.len() != 3 {
272            errors.push(ValidationError::LengthValidation {
273                field_tag: "71F".to_string(),
274                expected: "3 characters".to_string(),
275                actual: self.currency.len(),
276            });
277        }
278
279        if !self
280            .currency
281            .chars()
282            .all(|c| c.is_alphabetic() && c.is_ascii())
283        {
284            errors.push(ValidationError::FormatValidation {
285                field_tag: "71F".to_string(),
286                message: "Currency code must contain only alphabetic characters".to_string(),
287            });
288        }
289
290        // Validate amount
291        if self.amount < 0.0 {
292            errors.push(ValidationError::ValueValidation {
293                field_tag: "71F".to_string(),
294                message: "Charge amount cannot be negative".to_string(),
295            });
296        }
297
298        // Validate raw amount format
299        if self.raw_amount.is_empty() {
300            errors.push(ValidationError::ValueValidation {
301                field_tag: "71F".to_string(),
302                message: "Charge amount cannot be empty".to_string(),
303            });
304        }
305
306        ValidationResult {
307            is_valid: errors.is_empty(),
308            errors,
309            warnings: Vec::new(),
310        }
311    }
312
313    fn format_spec() -> &'static str {
314        "3!a15d"
315    }
316}
317
318impl std::fmt::Display for Field71F {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        write!(f, "{} {}", self.currency, self.raw_amount)
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_field71f_creation() {
330        let field = Field71F::new("USD", 10.50).unwrap();
331        assert_eq!(field.currency(), "USD");
332        assert_eq!(field.amount(), 10.50);
333        assert_eq!(field.raw_amount(), "10,50");
334    }
335
336    #[test]
337    fn test_field71f_from_raw() {
338        let field = Field71F::from_raw("EUR", "25,75").unwrap();
339        assert_eq!(field.currency(), "EUR");
340        assert_eq!(field.amount(), 25.75);
341        assert_eq!(field.raw_amount(), "25,75");
342    }
343
344    #[test]
345    fn test_field71f_parse() {
346        let field = Field71F::parse("USD15,00").unwrap();
347        assert_eq!(field.currency(), "USD");
348        assert_eq!(field.amount(), 15.0);
349        assert_eq!(field.raw_amount(), "15,00");
350    }
351
352    #[test]
353    fn test_field71f_parse_with_prefix() {
354        let field = Field71F::parse(":71F:GBP5,25").unwrap();
355        assert_eq!(field.currency(), "GBP");
356        assert_eq!(field.amount(), 5.25);
357        assert_eq!(field.raw_amount(), "5,25");
358    }
359
360    #[test]
361    fn test_field71f_to_swift_string() {
362        let field = Field71F::new("CHF", 100.0).unwrap();
363        assert_eq!(field.to_swift_string(), ":71F:CHF100,00");
364    }
365
366    #[test]
367    fn test_field71f_invalid_currency() {
368        let result = Field71F::new("US", 10.0);
369        assert!(result.is_err());
370
371        let result = Field71F::new("123", 10.0);
372        assert!(result.is_err());
373    }
374
375    #[test]
376    fn test_field71f_negative_amount() {
377        let result = Field71F::new("USD", -10.0);
378        assert!(result.is_err());
379    }
380
381    #[test]
382    fn test_field71f_validation() {
383        let field = Field71F::new("USD", 50.0).unwrap();
384        let validation = field.validate();
385        assert!(validation.is_valid);
386        assert!(validation.errors.is_empty());
387    }
388
389    #[test]
390    fn test_field71f_display() {
391        let field = Field71F::new("EUR", 75.50).unwrap();
392        assert_eq!(format!("{}", field), "EUR 75,50");
393    }
394
395    #[test]
396    fn test_field71f_description() {
397        let field = Field71F::new("USD", 20.0).unwrap();
398        assert_eq!(field.description(), "Sender's Charges: USD 20,00");
399    }
400}