swift_mt_message/fields/
field13.rs

1//! **Field 13: Time/Date Indication**
2//!
3//! Provides time and date indication with timezone offset for time-sensitive payment processing and settlement timing.
4
5use super::swift_utils::{parse_date_yymmdd, parse_exact_length, parse_numeric, parse_time_hhmm};
6use crate::errors::ParseError;
7use crate::traits::SwiftField;
8use chrono::{NaiveDate, NaiveTime};
9use serde::{Deserialize, Serialize};
10
11/// Helper module for serializing/deserializing NaiveTime as HHMM string
12mod time_format {
13    use chrono::{NaiveTime, Timelike};
14    use serde::{Deserialize, Deserializer, Serializer};
15
16    pub fn serialize<S>(time: &NaiveTime, serializer: S) -> Result<S::Ok, S::Error>
17    where
18        S: Serializer,
19    {
20        let s = format!("{:02}{:02}", time.hour(), time.minute());
21        serializer.serialize_str(&s)
22    }
23
24    pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveTime, D::Error>
25    where
26        D: Deserializer<'de>,
27    {
28        let s = String::deserialize(deserializer)?;
29        if s.len() != 4 {
30            return Err(serde::de::Error::custom("Time must be 4 digits (HHMM)"));
31        }
32        let hours: u32 = s[0..2].parse().map_err(serde::de::Error::custom)?;
33        let minutes: u32 = s[2..4].parse().map_err(serde::de::Error::custom)?;
34
35        NaiveTime::from_hms_opt(hours, minutes, 0)
36            .ok_or_else(|| serde::de::Error::custom(format!("Invalid time: {}:{}", hours, minutes)))
37    }
38}
39
40/// Helper module for serializing/deserializing NaiveDate as YYMMDD string
41mod date_format {
42    use chrono::NaiveDate;
43    use serde::{Deserialize, Deserializer, Serializer};
44
45    pub fn serialize<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
46    where
47        S: Serializer,
48    {
49        let s = date.format("%y%m%d").to_string();
50        serializer.serialize_str(&s)
51    }
52
53    pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
54    where
55        D: Deserializer<'de>,
56    {
57        let s = String::deserialize(deserializer)?;
58        if s.len() != 6 {
59            return Err(serde::de::Error::custom("Date must be 6 digits (YYMMDD)"));
60        }
61
62        let year: i32 = s[0..2].parse::<i32>().map_err(serde::de::Error::custom)?;
63        let year = if year >= 80 { 1900 + year } else { 2000 + year };
64        let month: u32 = s[2..4].parse().map_err(serde::de::Error::custom)?;
65        let day: u32 = s[4..6].parse().map_err(serde::de::Error::custom)?;
66
67        NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| {
68            serde::de::Error::custom(format!("Invalid date: {}/{}/{}", year, month, day))
69        })
70    }
71}
72
73/// **Field 13C: Time Indication**
74///
75/// Specifies time indication with timezone offset for payment processing events.
76///
77/// **Format:** `/8c/4!n1!x4!n` (code + time + offset sign + offset)
78/// **Valid Codes:** SNDTIME, CLSTIME, RNCTIME, REJTIME, CUTTIME
79///
80/// **Example:**
81/// ```text
82/// :13C:/SNDTIME/1230+0100
83/// ```
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct Field13C {
86    /// Time indication code (SNDTIME, CLSTIME, etc.)
87    pub code: String,
88
89    /// Time in HHMM format (24-hour)
90    #[serde(with = "time_format")]
91    pub time: NaiveTime,
92
93    /// UTC offset sign (+ or -)
94    pub sign: char,
95
96    /// UTC offset in HHMM format
97    pub offset: String,
98}
99
100impl SwiftField for Field13C {
101    fn parse(input: &str) -> crate::Result<Self>
102    where
103        Self: Sized,
104    {
105        // Minimum: /8c/4!n1!x4!n = / + 8 + / + 4 + 1 + 4 = 18 chars minimum
106        if input.len() < 10 {
107            // At minimum we need /X/ + time + sign + offset
108            return Err(ParseError::InvalidFormat {
109                message: format!(
110                    "Field 13C must be at least 10 characters, found {}",
111                    input.len()
112                ),
113            });
114        }
115
116        // Parse time indication code (must be between slashes)
117        if !input.starts_with('/') {
118            return Err(ParseError::InvalidFormat {
119                message: "Field 13C code must start with '/'".to_string(),
120            });
121        }
122
123        let end_slash = input[1..].find('/').ok_or(ParseError::InvalidFormat {
124            message: "Field 13C code must be enclosed in slashes".to_string(),
125        })? + 1;
126
127        if end_slash < 2 {
128            return Err(ParseError::InvalidFormat {
129                message: "Field 13C code cannot be empty".to_string(),
130            });
131        }
132
133        let code = input[1..end_slash].to_string();
134
135        // Validate against known codes
136        const VALID_CODES: &[&str] = &["SNDTIME", "CLSTIME", "RNCTIME", "REJTIME", "CUTTIME"];
137        if !VALID_CODES.contains(&code.as_str()) {
138            return Err(ParseError::InvalidFormat {
139                message: format!(
140                    "Field 13C code must be one of {:?}, found {}",
141                    VALID_CODES, code
142                ),
143            });
144        }
145
146        let remaining = &input[end_slash + 1..];
147        if remaining.len() != 9 {
148            // 4 (time) + 1 (sign) + 4 (offset)
149            return Err(ParseError::InvalidFormat {
150                message: format!(
151                    "Field 13C after code must be exactly 9 characters, found {}",
152                    remaining.len()
153                ),
154            });
155        }
156
157        // Parse time (4 digits)
158        let time_str = &remaining[0..4];
159        parse_numeric(time_str, "Field 13C time")?;
160        let time = parse_time_hhmm(time_str)?;
161
162        // Parse UTC offset sign
163        let sign_char = remaining.chars().nth(4).unwrap();
164        if sign_char != '+' && sign_char != '-' {
165            return Err(ParseError::InvalidFormat {
166                message: format!(
167                    "Field 13C UTC offset sign must be '+' or '-', found '{}'",
168                    sign_char
169                ),
170            });
171        }
172
173        // Parse offset (4 digits)
174        let offset = parse_exact_length(&remaining[5..9], 4, "Field 13C offset")?;
175        parse_numeric(&offset, "Field 13C offset")?;
176
177        // Validate offset is reasonable (up to 14 hours)
178        let offset_hours: u32 = offset[0..2].parse().unwrap();
179        let offset_minutes: u32 = offset[2..4].parse().unwrap();
180        if offset_hours > 14 || offset_minutes > 59 {
181            return Err(ParseError::InvalidFormat {
182                message: format!(
183                    "Field 13C offset must be valid time offset, found {}:{}",
184                    offset_hours, offset_minutes
185                ),
186            });
187        }
188
189        Ok(Field13C {
190            code,
191            time,
192            sign: sign_char,
193            offset,
194        })
195    }
196
197    fn to_swift_string(&self) -> String {
198        format!(
199            ":13C:/{}/{}{}{}",
200            self.code,
201            self.time.format("%H%M"),
202            self.sign,
203            self.offset
204        )
205    }
206}
207
208/// **Field 13D: Date/Time Indication**
209///
210/// Complete date and time indication with timezone offset for precise timestamping.
211///
212/// **Format:** `6!n4!n1!x4!n` (date + time + offset sign + offset)
213///
214/// **Example:**
215/// ```text
216/// :13D:2407191230+0100
217/// ```
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219pub struct Field13D {
220    /// Date in YYMMDD format
221    #[serde(with = "date_format")]
222    pub date: NaiveDate,
223
224    /// Time in HHMM format (24-hour)
225    #[serde(with = "time_format")]
226    pub time: NaiveTime,
227
228    /// UTC offset sign (+ or -)
229    pub offset_sign: char,
230
231    /// UTC offset in HHMM format
232    pub offset: String,
233}
234
235impl SwiftField for Field13D {
236    fn parse(input: &str) -> crate::Result<Self>
237    where
238        Self: Sized,
239    {
240        // Must be exactly 15 characters: 6 (date) + 4 (time) + 1 (sign) + 4 (offset)
241        if input.len() != 15 {
242            return Err(ParseError::InvalidFormat {
243                message: format!(
244                    "Field 13D must be exactly 15 characters, found {}",
245                    input.len()
246                ),
247            });
248        }
249
250        // Parse date (first 6 digits)
251        let date = parse_date_yymmdd(&input[0..6])?;
252
253        // Parse time (next 4 digits)
254        let time_str = &input[6..10];
255        parse_numeric(time_str, "Field 13D time")?;
256        let time = parse_time_hhmm(time_str)?;
257
258        // Parse UTC offset sign
259        let offset_sign = input.chars().nth(10).unwrap();
260        if offset_sign != '+' && offset_sign != '-' {
261            return Err(ParseError::InvalidFormat {
262                message: format!(
263                    "Field 13D UTC offset sign must be '+' or '-', found '{}'",
264                    offset_sign
265                ),
266            });
267        }
268
269        // Parse offset (last 4 digits)
270        let offset = parse_exact_length(&input[11..15], 4, "Field 13D offset")?;
271        parse_numeric(&offset, "Field 13D offset")?;
272
273        // Validate offset is reasonable
274        let offset_hours: u32 = offset[0..2].parse().unwrap();
275        let offset_minutes: u32 = offset[2..4].parse().unwrap();
276        if offset_hours > 14 || offset_minutes > 59 {
277            return Err(ParseError::InvalidFormat {
278                message: format!(
279                    "Field 13D offset must be valid time offset, found {}:{}",
280                    offset_hours, offset_minutes
281                ),
282            });
283        }
284
285        Ok(Field13D {
286            date,
287            time,
288            offset_sign,
289            offset,
290        })
291    }
292
293    fn to_swift_string(&self) -> String {
294        format!(
295            ":13D:{}{}{}{}",
296            self.date.format("%y%m%d"),
297            self.time.format("%H%M"),
298            self.offset_sign,
299            self.offset
300        )
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_field13c_valid() {
310        let field = Field13C::parse("/SNDTIME/1230+0100").unwrap();
311        assert_eq!(field.code, "SNDTIME");
312        assert_eq!(field.time.format("%H%M").to_string(), "1230");
313        assert_eq!(field.sign, '+');
314        assert_eq!(field.offset, "0100");
315        assert_eq!(field.to_swift_string(), ":13C:/SNDTIME/1230+0100");
316
317        let field = Field13C::parse("/CLSTIME/0900-0500").unwrap();
318        assert_eq!(field.code, "CLSTIME");
319        assert_eq!(field.time.format("%H%M").to_string(), "0900");
320        assert_eq!(field.sign, '-');
321        assert_eq!(field.offset, "0500");
322    }
323
324    #[test]
325    fn test_field13c_invalid() {
326        // Missing slashes
327        assert!(Field13C::parse("SNDTIME1230+0100").is_err());
328
329        // Invalid code
330        assert!(Field13C::parse("/BADCODE/1230+0100").is_err());
331
332        // Invalid time
333        assert!(Field13C::parse("/SNDTIME/2500+0100").is_err());
334
335        // Invalid sign
336        assert!(Field13C::parse("/SNDTIME/1230*0100").is_err());
337
338        // Invalid offset
339        assert!(Field13C::parse("/SNDTIME/1230+2500").is_err());
340
341        // Wrong length
342        assert!(Field13C::parse("/SNDTIME/1230+01").is_err());
343    }
344
345    #[test]
346    fn test_field13d_valid() {
347        let field = Field13D::parse("2407191230+0100").unwrap();
348        assert_eq!(field.date.format("%y%m%d").to_string(), "240719");
349        assert_eq!(field.time.format("%H%M").to_string(), "1230");
350        assert_eq!(field.offset_sign, '+');
351        assert_eq!(field.offset, "0100");
352        assert_eq!(field.to_swift_string(), ":13D:2407191230+0100");
353
354        let field = Field13D::parse("2412310000-0800").unwrap();
355        assert_eq!(field.date.format("%y%m%d").to_string(), "241231");
356        assert_eq!(field.time.format("%H%M").to_string(), "0000");
357        assert_eq!(field.offset_sign, '-');
358        assert_eq!(field.offset, "0800");
359    }
360
361    #[test]
362    fn test_field13d_invalid() {
363        // Wrong length
364        assert!(Field13D::parse("2407191230+01").is_err());
365        assert!(Field13D::parse("2407191230+010000").is_err());
366
367        // Invalid date
368        assert!(Field13D::parse("9913321230+0100").is_err());
369
370        // Invalid time
371        assert!(Field13D::parse("2407192500+0100").is_err());
372
373        // Invalid sign
374        assert!(Field13D::parse("2407191230*0100").is_err());
375
376        // Invalid offset
377        assert!(Field13D::parse("2407191230+2500").is_err());
378    }
379}