Skip to main content

klirr_core/models/
date.rs

1use crate::prelude::*;
2
3/// A date relevant for the invoice, e.g. invoice date, due date or a transaction
4/// date for an expense.
5#[derive(
6    Clone,
7    Copy,
8    Debug,
9    Display,
10    PartialEq,
11    Eq,
12    Hash,
13    SerializeDisplay,
14    DeserializeFromStr,
15    Builder,
16    Getters,
17)]
18#[display("{year:04}-{month:02}-{day:02}")]
19pub struct Date {
20    /// e.g. 2025
21    #[getset(get = "pub")]
22    year: Year,
23
24    /// e.g. 5 for May
25    #[getset(get = "pub")]
26    month: Month,
27
28    /// e.g. 31 for the last day of May
29    #[getset(get = "pub")]
30    day: Day,
31}
32
33impl std::str::FromStr for Date {
34    type Err = crate::prelude::Error;
35
36    /// Parses a date in the format "YYYY-MM-DD", e.g. "2025-05-23".
37    /// # Errors
38    /// Returns an error if the string is not in the correct format or if the
39    /// year, month, or day is invalid.
40    ///
41    /// # Examples
42    /// ```
43    /// extern crate klirr_core;
44    /// use klirr_core::prelude::*;
45    /// let date: Date = "2025-05-23".parse().unwrap();
46    /// assert_eq!(date.year(), &Year::from(2025));
47    /// assert_eq!(date.month(), &Month::May);
48    /// assert_eq!(date.day(), &Day::try_from(23).unwrap());    
49    /// ```
50    fn from_str(s: &str) -> Result<Self, Self::Err> {
51        let parts: Vec<&str> = s.split('-').collect();
52        if parts.len() != 3 {
53            return Err(Error::FailedToParseDate {
54                underlying: "Invalid Format".to_owned(),
55            });
56        }
57
58        let year = Year::from_str(parts[0])?;
59        let month = Month::from_str(parts[1])?;
60        let day = Day::from_str(parts[2])?;
61
62        Ok(Self::builder().year(year).month(month).day(day).build())
63    }
64}
65
66fn from_ymd_parts(year: i32, month: u32, day: u32) -> Date {
67    Date::builder()
68        .year(year.into())
69        .month(Month::try_from(month).expect("Invalid month"))
70        .day(Day::try_from(day).expect("Invalid day"))
71        .build()
72}
73
74impl From<NaiveDate> for Date {
75    fn from(value: NaiveDate) -> Self {
76        from_ymd_parts(value.year(), value.month(), value.day())
77    }
78}
79
80impl From<NaiveDateTime> for Date {
81    fn from(value: NaiveDateTime) -> Self {
82        from_ymd_parts(value.year(), value.month(), value.day())
83    }
84}
85
86impl Date {
87    pub fn to_datetime(&self) -> NaiveDateTime {
88        let naive_date = chrono::NaiveDate::from_ymd_opt(
89            **self.year() as i32,
90            **self.month() as u32,
91            **self.day() as u32,
92        )
93        .expect("Invalid date components");
94        naive_date
95            .and_hms_opt(0, 0, 0)
96            .expect("Invalid time components")
97    }
98
99    pub fn advance_days(&self, days: &Day) -> Self {
100        let datetime = self.to_datetime();
101        let days: u8 = **days;
102        let advanced_date = datetime + chrono::Duration::days(days as i64);
103        Self::from(advanced_date)
104    }
105
106    pub fn advance(&self, terms: &PaymentTerms) -> Self {
107        match terms {
108            PaymentTerms::Net(days) => self.advance_days(days.due_in()),
109        }
110    }
111}
112
113impl HasSample for Date {
114    fn sample() -> Self {
115        Self::builder()
116            .year(2025.into())
117            .month(Month::May)
118            .day(Day::try_from(31).expect("LEQ 31 days"))
119            .build()
120    }
121    fn sample_other() -> Self {
122        Self::builder()
123            .year(2024.into())
124            .month(Month::December)
125            .day(Day::try_from(15).expect("LEQ 31 days"))
126            .build()
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use test_log::test;
134
135    type Sut = Date;
136
137    #[test]
138    fn equality() {
139        assert_eq!(Sut::sample(), Sut::sample());
140        assert_eq!(Sut::sample_other(), Sut::sample_other());
141    }
142
143    #[test]
144    fn inequality() {
145        assert_ne!(Sut::sample(), Sut::sample_other());
146    }
147
148    #[test]
149    fn test_date_from_str() {
150        let sut = Sut::from_str("2025-05-23").unwrap();
151        assert_eq!(sut.year(), &Year::from(2025));
152        assert_eq!(sut.month(), &Month::May);
153        assert_eq!(sut.day(), &Day::try_from(23).unwrap());
154    }
155
156    #[test]
157    fn test_year_month_from_str() {
158        let sut = YearAndMonth::from_str("2025-05").unwrap();
159        assert_eq!(sut.year(), &Year::from(2025));
160        assert_eq!(sut.month(), &Month::May);
161    }
162
163    #[test]
164    fn test_from_str_all_reasons_invalid() {
165        let invalid_dates = [
166            "2025-05-32",        // Invalid day
167            "99999999999-05-32", // Invalid year
168            "2025-13-01",        // Invalid month
169            "2025-00-01",        // Invalid month zero
170            "2025-13-01",        // Invalid month too large
171            "2025-05",           // Missing day
172            "2025",              // Missing month and day
173            "05-23",             // Missing year
174            "2025-05-23-01",     // Too many parts
175        ];
176
177        for date in invalid_dates {
178            assert!(Sut::from_str(date).is_err());
179        }
180    }
181
182    #[test]
183    fn test_from_naive_date() {
184        let naive_date = NaiveDate::from_ymd_opt(2025, 5, 23).unwrap();
185        let date: Date = naive_date.into();
186        assert_eq!(date.year(), &Year::from(2025));
187        assert_eq!(date.month(), &Month::May);
188        assert_eq!(date.day(), &Day::try_from(23).unwrap());
189    }
190}