swift_mt_message/fields/
field32.rs

1//! # Field 32: Value Date, Currency, Amount
2//!
3//! Settlement amount and value date for payment instructions.
4//!
5//! **Variants:**
6//! - **32A:** Date + Currency + Amount (YYMMDD + 3!a + 15d)
7//! - **32B:** Currency + Amount (3!a + 15d)
8//! - **32C:** Date + Currency + Credit Amount (MT n90 messages)
9//! - **32D:** Date + Currency + Debit Amount (MT n90 messages)
10//!
11//! **Example:**
12//! ```text
13//! :32A:240719USD1000,50
14//! :32B:EUR500,00
15//! ```
16
17use super::swift_utils::{
18    format_swift_amount_for_currency, parse_amount_with_currency, parse_currency_non_commodity,
19    parse_date_yymmdd,
20};
21use crate::errors::ParseError;
22use crate::traits::SwiftField;
23use chrono::NaiveDate;
24use serde::{Deserialize, Serialize};
25
26/// **Field 32A: Value Date, Currency, Amount**
27///
28/// Settlement information with value date.
29/// Format: `6!n3!a15d` (YYMMDD + currency + amount)
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct Field32A {
32    /// Value date (YYMMDD)
33    #[serde(with = "date_string")]
34    pub value_date: NaiveDate,
35    /// ISO 4217 currency code
36    pub currency: String,
37    /// Settlement amount
38    pub amount: f64,
39}
40
41impl SwiftField for Field32A {
42    fn parse(input: &str) -> crate::Result<Self>
43    where
44        Self: Sized,
45    {
46        // Field32A format: 6!n3!a15d (date + currency + amount)
47        if input.len() < 10 {
48            // Minimum: 6 digits date + 3 chars currency + 1 digit amount
49            return Err(ParseError::InvalidFormat {
50                message: format!(
51                    "Field 32A must be at least 10 characters, found {}",
52                    input.len()
53                ),
54            });
55        }
56
57        // Parse value date (first 6 characters) - T50 validation
58        let value_date = parse_date_yymmdd(&input[0..6])?;
59
60        // Parse currency code (next 3 characters) - T52 + C08 validation
61        let currency = parse_currency_non_commodity(&input[6..9])?;
62
63        // Parse amount (remaining characters) - T40/T43 + C03 validation
64        let amount_str = &input[9..];
65        if amount_str.is_empty() {
66            return Err(ParseError::InvalidFormat {
67                message: "Field 32A amount cannot be empty".to_string(),
68            });
69        }
70
71        let amount = parse_amount_with_currency(amount_str, &currency)?;
72
73        // Amount must be positive
74        if amount <= 0.0 {
75            return Err(ParseError::InvalidFormat {
76                message: "Field 32A amount must be greater than zero".to_string(),
77            });
78        }
79
80        Ok(Field32A {
81            value_date,
82            currency,
83            amount,
84        })
85    }
86
87    fn to_swift_string(&self) -> String {
88        format!(
89            ":32A:{}{}{}",
90            self.value_date.format("%y%m%d"),
91            self.currency,
92            format_swift_amount_for_currency(self.amount, &self.currency)
93        )
94    }
95}
96
97/// **Field 32B: Currency, Amount**
98///
99/// Currency and amount without value date.
100/// Format: `3!a15d` (currency + amount)
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct Field32B {
103    /// ISO 4217 currency code
104    pub currency: String,
105    /// Amount
106    pub amount: f64,
107}
108
109impl SwiftField for Field32B {
110    fn parse(input: &str) -> crate::Result<Self>
111    where
112        Self: Sized,
113    {
114        // Field32B format: 3!a15d (currency + amount)
115        if input.len() < 4 {
116            // Minimum: 3 chars currency + 1 digit amount
117            return Err(ParseError::InvalidFormat {
118                message: format!(
119                    "Field 32B must be at least 4 characters, found {}",
120                    input.len()
121                ),
122            });
123        }
124
125        // Parse currency code (first 3 characters) - T52 + C08 validation
126        let currency = parse_currency_non_commodity(&input[0..3])?;
127
128        // Parse amount (remaining characters) - T40/T43 + C03 validation
129        let amount_str = &input[3..];
130        if amount_str.is_empty() {
131            return Err(ParseError::InvalidFormat {
132                message: "Field 32B amount cannot be empty".to_string(),
133            });
134        }
135
136        let amount = parse_amount_with_currency(amount_str, &currency)?;
137
138        // Amount must be positive
139        if amount <= 0.0 {
140            return Err(ParseError::InvalidFormat {
141                message: "Field 32B amount must be greater than zero".to_string(),
142            });
143        }
144
145        Ok(Field32B { currency, amount })
146    }
147
148    fn to_swift_string(&self) -> String {
149        format!(
150            ":32B:{}{}",
151            self.currency,
152            format_swift_amount_for_currency(self.amount, &self.currency)
153        )
154    }
155}
156
157/// **Field 32C: Value Date, Currency, Credit Amount**
158///
159/// Credit amount with value date (MT n90 messages).
160/// Format: `6!n3!a15d`
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct Field32C {
163    /// Value date (YYMMDD)
164    #[serde(with = "date_string")]
165    pub value_date: NaiveDate,
166    /// ISO 4217 currency code
167    pub currency: String,
168    /// Credit amount
169    pub amount: f64,
170}
171
172// Custom serialization for dates as strings
173mod date_string {
174    use chrono::NaiveDate;
175    use serde::{Deserialize, Deserializer, Serializer};
176
177    pub fn serialize<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
178    where
179        S: Serializer,
180    {
181        serializer.serialize_str(&date.format("%Y-%m-%d").to_string())
182    }
183
184    pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
185    where
186        D: Deserializer<'de>,
187    {
188        let s = String::deserialize(deserializer)?;
189        NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(serde::de::Error::custom)
190    }
191}
192
193impl SwiftField for Field32C {
194    fn parse(input: &str) -> crate::Result<Self>
195    where
196        Self: Sized,
197    {
198        // Same format as Field32A
199        if input.len() < 10 {
200            return Err(ParseError::InvalidFormat {
201                message: format!(
202                    "Field 32C must be at least 10 characters, found {}",
203                    input.len()
204                ),
205            });
206        }
207
208        let value_date = parse_date_yymmdd(&input[0..6])?;
209        let currency = parse_currency_non_commodity(&input[6..9])?;
210        let amount_str = &input[9..];
211
212        if amount_str.is_empty() {
213            return Err(ParseError::InvalidFormat {
214                message: "Field 32C amount cannot be empty".to_string(),
215            });
216        }
217
218        let amount = parse_amount_with_currency(amount_str, &currency)?;
219
220        if amount <= 0.0 {
221            return Err(ParseError::InvalidFormat {
222                message: "Field 32C amount must be greater than zero".to_string(),
223            });
224        }
225
226        Ok(Field32C {
227            value_date,
228            currency,
229            amount,
230        })
231    }
232
233    fn to_swift_string(&self) -> String {
234        format!(
235            ":32C:{}{}{}",
236            self.value_date.format("%y%m%d"),
237            self.currency,
238            format_swift_amount_for_currency(self.amount, &self.currency)
239        )
240    }
241}
242
243/// **Field 32D: Value Date, Currency, Debit Amount**
244///
245/// Debit amount with value date (MT n90 messages).
246/// Format: `6!n3!a15d`
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248pub struct Field32D {
249    /// Value date (YYMMDD)
250    #[serde(with = "date_string")]
251    pub value_date: NaiveDate,
252    /// ISO 4217 currency code
253    pub currency: String,
254    /// Debit amount
255    pub amount: f64,
256}
257
258impl SwiftField for Field32D {
259    fn parse(input: &str) -> crate::Result<Self>
260    where
261        Self: Sized,
262    {
263        // Same format as Field32A
264        if input.len() < 10 {
265            return Err(ParseError::InvalidFormat {
266                message: format!(
267                    "Field 32D must be at least 10 characters, found {}",
268                    input.len()
269                ),
270            });
271        }
272
273        let value_date = parse_date_yymmdd(&input[0..6])?;
274        let currency = parse_currency_non_commodity(&input[6..9])?;
275        let amount_str = &input[9..];
276
277        if amount_str.is_empty() {
278            return Err(ParseError::InvalidFormat {
279                message: "Field 32D amount cannot be empty".to_string(),
280            });
281        }
282
283        let amount = parse_amount_with_currency(amount_str, &currency)?;
284
285        if amount <= 0.0 {
286            return Err(ParseError::InvalidFormat {
287                message: "Field 32D amount must be greater than zero".to_string(),
288            });
289        }
290
291        Ok(Field32D {
292            value_date,
293            currency,
294            amount,
295        })
296    }
297
298    fn to_swift_string(&self) -> String {
299        format!(
300            ":32D:{}{}{}",
301            self.value_date.format("%y%m%d"),
302            self.currency,
303            format_swift_amount_for_currency(self.amount, &self.currency)
304        )
305    }
306}
307
308/// **Field 32: Settlement Amount Variants**
309///
310/// Enum wrapper for Field 32 variants (A/B/C/D).
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub enum Field32 {
313    #[serde(rename = "32A")]
314    A(Field32A),
315    #[serde(rename = "32B")]
316    B(Field32B),
317    #[serde(rename = "32C")]
318    C(Field32C),
319    #[serde(rename = "32D")]
320    D(Field32D),
321}
322
323impl SwiftField for Field32 {
324    fn parse(input: &str) -> crate::Result<Self>
325    where
326        Self: Sized,
327    {
328        // Try to determine variant based on content
329        // If it starts with 6 digits (date), it's A, C, or D
330        // Otherwise it's B (currency + amount only)
331        if input.len() >= 6 {
332            // Check if first 6 chars are digits (date)
333            if input[0..6].chars().all(|c| c.is_ascii_digit()) {
334                // Default to A for date variants
335                Ok(Field32::A(Field32A::parse(input)?))
336            } else if input.len() >= 3 && input[0..3].chars().all(|c| c.is_ascii_alphabetic()) {
337                // Starts with currency, must be B
338                Ok(Field32::B(Field32B::parse(input)?))
339            } else {
340                Err(ParseError::InvalidFormat {
341                    message:
342                        "Field 32 must start with either date (6 digits) or currency (3 letters)"
343                            .to_string(),
344                })
345            }
346        } else {
347            Err(ParseError::InvalidFormat {
348                message: format!(
349                    "Field 32 must be at least 6 characters, found {}",
350                    input.len()
351                ),
352            })
353        }
354    }
355
356    fn to_swift_string(&self) -> String {
357        match self {
358            Field32::A(field) => field.to_swift_string(),
359            Field32::B(field) => field.to_swift_string(),
360            Field32::C(field) => field.to_swift_string(),
361            Field32::D(field) => field.to_swift_string(),
362        }
363    }
364}
365
366/// **Field32AB: Options A or B only**
367///
368/// Used in MT110, MT111, MT112 (cheque messages).
369/// Supports only 32A (with date) or 32B (without date).
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371pub enum Field32AB {
372    #[serde(rename = "32A")]
373    A(Field32A),
374    #[serde(rename = "32B")]
375    B(Field32B),
376}
377
378impl SwiftField for Field32AB {
379    fn parse(input: &str) -> crate::Result<Self>
380    where
381        Self: Sized,
382    {
383        // Try parsing as Field32A first (has value date)
384        if let Ok(field) = Field32A::parse(input) {
385            return Ok(Field32AB::A(field));
386        }
387
388        // If that fails, try as Field32B (no value date)
389        if let Ok(field) = Field32B::parse(input) {
390            return Ok(Field32AB::B(field));
391        }
392
393        Err(ParseError::InvalidFormat {
394            message: "Field 32 must be either format 32A (YYMMDD + Currency + Amount) or 32B (Currency + Amount)".to_string(),
395        })
396    }
397
398    fn to_swift_string(&self) -> String {
399        match self {
400            Field32AB::A(field) => field.to_swift_string(),
401            Field32AB::B(field) => field.to_swift_string(),
402        }
403    }
404}
405
406/// **Field32AmountCD: Credit or Debit**
407///
408/// Used in MT190 and similar messages.
409/// Supports 32C (credit) or 32D (debit).
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411pub enum Field32AmountCD {
412    #[serde(rename = "32C")]
413    C(Field32C),
414    #[serde(rename = "32D")]
415    D(Field32D),
416}
417
418impl SwiftField for Field32AmountCD {
419    fn parse(input: &str) -> crate::Result<Self>
420    where
421        Self: Sized,
422    {
423        // Both C and D variants have the same format (date + currency + amount)
424        // Try to parse as Field32C first (credit)
425        if let Ok(field) = Field32C::parse(input) {
426            return Ok(Field32AmountCD::C(field));
427        }
428
429        // If that fails, try as Field32D (debit)
430        if let Ok(field) = Field32D::parse(input) {
431            return Ok(Field32AmountCD::D(field));
432        }
433
434        Err(ParseError::InvalidFormat {
435            message: "Field 32 must be in format: YYMMDD + Currency + Amount".to_string(),
436        })
437    }
438
439    fn parse_with_variant(
440        value: &str,
441        variant: Option<&str>,
442        _field_tag: Option<&str>,
443    ) -> crate::Result<Self>
444    where
445        Self: Sized,
446    {
447        // Use the variant letter to determine which type to parse
448        match variant {
449            Some("C") => {
450                let field = Field32C::parse(value)?;
451                Ok(Field32AmountCD::C(field))
452            }
453            Some("D") => {
454                let field = Field32D::parse(value)?;
455                Ok(Field32AmountCD::D(field))
456            }
457            _ => {
458                // No variant specified, fall back to default parse behavior
459                Self::parse(value)
460            }
461        }
462    }
463
464    fn to_swift_string(&self) -> String {
465        match self {
466            Field32AmountCD::C(field) => field.to_swift_string(),
467            Field32AmountCD::D(field) => field.to_swift_string(),
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use chrono::NaiveDate;
476
477    #[test]
478    fn test_field32a_valid() {
479        let field = Field32A::parse("240719EUR1250,50").unwrap();
480        assert_eq!(
481            field.value_date,
482            NaiveDate::from_ymd_opt(2024, 7, 19).unwrap()
483        );
484        assert_eq!(field.currency, "EUR");
485        assert_eq!(field.amount, 1250.50);
486        assert_eq!(
487            field.to_swift_string(),
488            ":32A:240719EUR1250.5".replace('.', ",")
489        );
490
491        let field = Field32A::parse("240720USD10000,00").unwrap();
492        assert_eq!(field.currency, "USD");
493        assert_eq!(field.amount, 10000.0);
494
495        let field = Field32A::parse("240721JPY1500000").unwrap();
496        assert_eq!(field.currency, "JPY");
497        assert_eq!(field.amount, 1500000.0);
498    }
499
500    #[test]
501    fn test_field32a_invalid() {
502        // Invalid date
503        assert!(Field32A::parse("991332EUR100").is_err());
504
505        // Invalid currency
506        assert!(Field32A::parse("240719EU1100").is_err());
507        assert!(Field32A::parse("2407191UR100").is_err());
508
509        // Zero amount
510        assert!(Field32A::parse("240719EUR0").is_err());
511
512        // Negative amount
513        assert!(Field32A::parse("240719EUR-100").is_err());
514
515        // Too short
516        assert!(Field32A::parse("240719EUR").is_err());
517    }
518
519    #[test]
520    fn test_field32b_valid() {
521        let field = Field32B::parse("EUR5000,00").unwrap();
522        assert_eq!(field.currency, "EUR");
523        assert_eq!(field.amount, 5000.0);
524        assert_eq!(field.to_swift_string(), ":32B:EUR5000");
525
526        let field = Field32B::parse("USD100").unwrap();
527        assert_eq!(field.currency, "USD");
528        assert_eq!(field.amount, 100.0);
529    }
530
531    #[test]
532    fn test_field32b_invalid() {
533        // Invalid currency
534        assert!(Field32B::parse("12A100").is_err());
535
536        // Zero amount
537        assert!(Field32B::parse("EUR0").is_err());
538
539        // Missing amount
540        assert!(Field32B::parse("EUR").is_err());
541    }
542
543    #[test]
544    fn test_field32c_valid() {
545        let field = Field32C::parse("240719EUR500,25").unwrap();
546        assert_eq!(
547            field.value_date,
548            NaiveDate::from_ymd_opt(2024, 7, 19).unwrap()
549        );
550        assert_eq!(field.currency, "EUR");
551        assert_eq!(field.amount, 500.25);
552    }
553
554    #[test]
555    fn test_field32d_valid() {
556        let field = Field32D::parse("240719USD750,50").unwrap();
557        assert_eq!(
558            field.value_date,
559            NaiveDate::from_ymd_opt(2024, 7, 19).unwrap()
560        );
561        assert_eq!(field.currency, "USD");
562        assert_eq!(field.amount, 750.50);
563    }
564
565    #[test]
566    fn test_field32_enum() {
567        // Should parse as Field32A (has date)
568        let field = Field32::parse("240719EUR1000").unwrap();
569        match field {
570            Field32::A(f) => {
571                assert_eq!(f.currency, "EUR");
572                assert_eq!(f.amount, 1000.0);
573            }
574            _ => panic!("Expected Field32::A"),
575        }
576
577        // Should parse as Field32B (no date)
578        let field = Field32::parse("EUR2000").unwrap();
579        match field {
580            Field32::B(f) => {
581                assert_eq!(f.currency, "EUR");
582                assert_eq!(f.amount, 2000.0);
583            }
584            _ => panic!("Expected Field32::B"),
585        }
586    }
587
588    #[test]
589    fn test_field32_ab() {
590        // Test parsing as Field32A (with value date)
591        let field = Field32AB::parse("240719EUR500,25").unwrap();
592        match field {
593            Field32AB::A(f) => {
594                assert_eq!(f.value_date, NaiveDate::from_ymd_opt(2024, 7, 19).unwrap());
595                assert_eq!(f.currency, "EUR");
596                assert_eq!(f.amount, 500.25);
597            }
598            _ => panic!("Expected Field32AB::A"),
599        }
600
601        // Test parsing as Field32B (no value date)
602        let field = Field32AB::parse("USD1000,00").unwrap();
603        match field {
604            Field32AB::B(f) => {
605                assert_eq!(f.currency, "USD");
606                assert_eq!(f.amount, 1000.00);
607            }
608            _ => panic!("Expected Field32AB::B"),
609        }
610
611        // Test to_swift_string for A (Field32A includes field tag)
612        let field_a = Field32AB::A(Field32A {
613            value_date: NaiveDate::from_ymd_opt(2024, 7, 19).unwrap(),
614            currency: "EUR".to_string(),
615            amount: 500.25,
616        });
617        assert_eq!(field_a.to_swift_string(), ":32A:240719EUR500,25");
618
619        // Test to_swift_string for B (Field32B includes field tag)
620        let field_b = Field32AB::B(Field32B {
621            currency: "USD".to_string(),
622            amount: 1000.00,
623        });
624        assert_eq!(field_b.to_swift_string(), ":32B:USD1000");
625    }
626
627    #[test]
628    fn test_field32_amount_cd() {
629        // Test parsing as credit (32C)
630        let field = Field32AmountCD::parse("240719EUR500,25").unwrap();
631        match field {
632            Field32AmountCD::C(f) => {
633                assert_eq!(f.value_date, NaiveDate::from_ymd_opt(2024, 7, 19).unwrap());
634                assert_eq!(f.currency, "EUR");
635                assert_eq!(f.amount, 500.25);
636            }
637            _ => panic!("Expected Field32AmountCD::C"),
638        }
639
640        // Test parsing as debit (32D) - same format
641        let field = Field32AmountCD::parse("240720USD750,50").unwrap();
642        match field {
643            Field32AmountCD::C(f) => {
644                // Since both C and D have same format, it will parse as C first
645                assert_eq!(f.value_date, NaiveDate::from_ymd_opt(2024, 7, 20).unwrap());
646                assert_eq!(f.currency, "USD");
647                assert_eq!(f.amount, 750.50);
648            }
649            _ => panic!("Expected Field32AmountCD::C"),
650        }
651
652        // Test to_swift_string
653        let credit_field = Field32AmountCD::C(Field32C {
654            value_date: NaiveDate::from_ymd_opt(2024, 7, 19).unwrap(),
655            currency: "EUR".to_string(),
656            amount: 500.25,
657        });
658        assert_eq!(credit_field.to_swift_string(), ":32C:240719EUR500,25");
659
660        let debit_field = Field32AmountCD::D(Field32D {
661            value_date: NaiveDate::from_ymd_opt(2024, 7, 20).unwrap(),
662            currency: "USD".to_string(),
663            amount: 750.50,
664        });
665        assert_eq!(debit_field.to_swift_string(), ":32D:240720USD750,5");
666    }
667
668    #[test]
669    fn test_field32a_c08_commodity_currency_rejection() {
670        // Test that commodity currencies are rejected (C08 validation)
671        assert!(Field32A::parse("240719XAU1000").is_err()); // Gold
672        assert!(Field32A::parse("240719XAG500").is_err()); // Silver
673        assert!(Field32A::parse("240719XPT250").is_err()); // Platinum
674        assert!(Field32A::parse("240719XPD100").is_err()); // Palladium
675
676        // Verify error message contains C08
677        let err = Field32A::parse("240719XAU1000").unwrap_err();
678        let err_msg = format!("{}", err);
679        assert!(err_msg.contains("C08"));
680    }
681
682    #[test]
683    fn test_field32a_c03_decimal_precision_validation() {
684        // USD allows 2 decimals
685        assert!(Field32A::parse("240719USD100.50").is_ok());
686        assert!(Field32A::parse("240719USD100,50").is_ok());
687        assert!(Field32A::parse("240719USD100.505").is_err()); // 3 decimals - should fail
688
689        // JPY allows 0 decimals
690        assert!(Field32A::parse("240719JPY1500000").is_ok());
691        assert!(Field32A::parse("240719JPY1500000.5").is_err()); // Has decimals - should fail
692
693        // BHD allows 3 decimals
694        assert!(Field32A::parse("240719BHD100.505").is_ok());
695        assert!(Field32A::parse("240719BHD100,505").is_ok());
696        assert!(Field32A::parse("240719BHD100.5055").is_err()); // 4 decimals - should fail
697
698        // Verify error message contains C03
699        let err = Field32A::parse("240719USD100.505").unwrap_err();
700        let err_msg = format!("{}", err);
701        assert!(err_msg.contains("C03"));
702    }
703
704    #[test]
705    fn test_field32a_currency_specific_formatting() {
706        // Test that to_swift_string uses currency-specific decimal places
707        let field_usd = Field32A {
708            value_date: NaiveDate::from_ymd_opt(2024, 7, 19).unwrap(),
709            currency: "USD".to_string(),
710            amount: 1000.50,
711        };
712        assert_eq!(field_usd.to_swift_string(), ":32A:240719USD1000,5");
713
714        let field_jpy = Field32A {
715            value_date: NaiveDate::from_ymd_opt(2024, 7, 19).unwrap(),
716            currency: "JPY".to_string(),
717            amount: 1500000.0,
718        };
719        assert_eq!(field_jpy.to_swift_string(), ":32A:240719JPY1500000");
720
721        let field_bhd = Field32A {
722            value_date: NaiveDate::from_ymd_opt(2024, 7, 19).unwrap(),
723            currency: "BHD".to_string(),
724            amount: 123.456,
725        };
726        assert_eq!(field_bhd.to_swift_string(), ":32A:240719BHD123,456");
727    }
728}