Skip to main content

turnframe_core/operation/
value.rs

1//! Values the model understands and code computes: dates and amounts of money.
2//!
3//! A model never computes «domani» or «10 euro»: it fills a [`DateExpr`] or a decimal
4//! string, and code evaluates it against the turn's clock or the currency's exponent.
5
6use chrono::{Datelike, Days, Months, NaiveDate, Weekday};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10/// A date as the user said it, for code to evaluate.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
12#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
13pub enum DateExpr {
14    /// A calendar date; without a year, the argument's direction picks it.
15    Absolute {
16        /// The year, when the user said one.
17        #[serde(default)]
18        year: Option<i32>,
19        /// Month, 1 to 12.
20        month: u32,
21        /// Day of the month.
22        day: u32,
23    },
24    /// Some units from today: «domani» is one day, «tra due settimane» two weeks.
25    Relative {
26        /// The unit counted.
27        unit: DateUnit,
28        /// How many, negative for the past.
29        amount: i32,
30    },
31    /// A day of the week.
32    Weekday {
33        /// Which day.
34        day: DayOfWeek,
35        /// Which occurrence of it.
36        which: WeekdayOccurrence,
37    },
38    /// The last day of a period: «fine mese» is the end of this month.
39    PeriodEnd {
40        /// The period.
41        period: DatePeriod,
42        /// Which one.
43        which: PeriodOccurrence,
44    },
45    /// The first day of a period.
46    PeriodStart {
47        /// The period.
48        period: DatePeriod,
49        /// Which one.
50        which: PeriodOccurrence,
51    },
52}
53
54/// A unit of [`DateExpr::Relative`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
56#[serde(rename_all = "snake_case")]
57pub enum DateUnit {
58    /// Days.
59    Day,
60    /// Weeks of seven days.
61    Week,
62    /// Calendar months; the day is clamped to the month's length.
63    Month,
64    /// Calendar years.
65    Year,
66}
67
68/// A day of the week.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
70#[serde(rename_all = "snake_case")]
71#[allow(missing_docs)]
72pub enum DayOfWeek {
73    Monday,
74    Tuesday,
75    Wednesday,
76    Thursday,
77    Friday,
78    Saturday,
79    Sunday,
80}
81
82/// Which occurrence of a weekday.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(rename_all = "snake_case")]
85pub enum WeekdayOccurrence {
86    /// The first one after today: «venerdì» said about a deadline.
87    Coming,
88    /// The last one before today.
89    Previous,
90    /// The one in the current week, Monday to Sunday.
91    ThisWeek,
92    /// The one in the following week.
93    NextWeek,
94    /// The one in the previous week.
95    LastWeek,
96}
97
98/// A calendar period.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100#[serde(rename_all = "snake_case")]
101#[allow(missing_docs)]
102pub enum DatePeriod {
103    Week,
104    Month,
105    Quarter,
106    Year,
107}
108
109/// Which period.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111#[serde(rename_all = "snake_case")]
112#[allow(missing_docs)]
113pub enum PeriodOccurrence {
114    This,
115    Next,
116    Last,
117}
118
119/// Which way a date without a year points.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum DateDirection {
123    /// The current year.
124    #[default]
125    Any,
126    /// Today or later: a due date.
127    Future,
128    /// Today or earlier: a date something happened.
129    Past,
130}
131
132/// Why a date expression names no date.
133#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
134#[error("{0}")]
135pub struct DateError(pub String);
136
137impl DateExpr {
138    /// The date this expression names on `today`.
139    ///
140    /// # Errors
141    ///
142    /// [`DateError`] for a day that does not exist, such as the 31st of April.
143    pub fn evaluate(
144        &self,
145        today: NaiveDate,
146        direction: DateDirection,
147    ) -> Result<NaiveDate, DateError> {
148        let missing = || DateError(format!("{self:?} names no date"));
149        match *self {
150            Self::Absolute { year, month, day } => absolute(today, year, month, day, direction),
151            Self::Relative { unit, amount } => relative(today, unit, amount).ok_or_else(missing),
152            Self::Weekday { day, which } => Ok(weekday(today, day.into(), which)),
153            Self::PeriodEnd { period, which } => period_bounds(today, period, which)
154                .map(|(_, end)| end)
155                .ok_or_else(missing),
156            Self::PeriodStart { period, which } => period_bounds(today, period, which)
157                .map(|(start, _)| start)
158                .ok_or_else(missing),
159        }
160    }
161}
162
163fn absolute(
164    today: NaiveDate,
165    year: Option<i32>,
166    month: u32,
167    day: u32,
168    direction: DateDirection,
169) -> Result<NaiveDate, DateError> {
170    let on = |year: i32| {
171        NaiveDate::from_ymd_opt(year, month, day)
172            .ok_or_else(|| DateError(format!("{year}-{month:02}-{day:02} does not exist")))
173    };
174    if let Some(year) = year {
175        return on(year);
176    }
177    let this_year = on(today.year())?;
178    Ok(match direction {
179        DateDirection::Future if this_year < today => on(today.year() + 1)?,
180        DateDirection::Past if this_year > today => on(today.year() - 1)?,
181        _ => this_year,
182    })
183}
184
185fn relative(today: NaiveDate, unit: DateUnit, amount: i32) -> Option<NaiveDate> {
186    let magnitude = amount.unsigned_abs();
187    let forward = amount >= 0;
188    match unit {
189        DateUnit::Day => shift_days(today, u64::from(magnitude), forward),
190        DateUnit::Week => shift_days(today, u64::from(magnitude) * 7, forward),
191        DateUnit::Month => shift_months(today, magnitude, forward),
192        DateUnit::Year => shift_months(today, magnitude.checked_mul(12)?, forward),
193    }
194}
195
196fn shift_days(date: NaiveDate, days: u64, forward: bool) -> Option<NaiveDate> {
197    if forward {
198        date.checked_add_days(Days::new(days))
199    } else {
200        date.checked_sub_days(Days::new(days))
201    }
202}
203
204fn shift_months(date: NaiveDate, months: u32, forward: bool) -> Option<NaiveDate> {
205    if forward {
206        date.checked_add_months(Months::new(months))
207    } else {
208        date.checked_sub_months(Months::new(months))
209    }
210}
211
212fn weekday(today: NaiveDate, day: Weekday, which: WeekdayOccurrence) -> NaiveDate {
213    let monday = today - chrono::Duration::days(i64::from(today.weekday().num_days_from_monday()));
214    let in_week = |week_start: NaiveDate| {
215        week_start + chrono::Duration::days(i64::from(day.num_days_from_monday()))
216    };
217    match which {
218        WeekdayOccurrence::ThisWeek => in_week(monday),
219        WeekdayOccurrence::NextWeek => in_week(monday + chrono::Duration::days(7)),
220        WeekdayOccurrence::LastWeek => in_week(monday - chrono::Duration::days(7)),
221        WeekdayOccurrence::Coming => {
222            let ahead = (7 + i64::from(day.num_days_from_monday())
223                - i64::from(today.weekday().num_days_from_monday()))
224                % 7;
225            today + chrono::Duration::days(if ahead == 0 { 7 } else { ahead })
226        }
227        WeekdayOccurrence::Previous => {
228            let behind = (7 + i64::from(today.weekday().num_days_from_monday())
229                - i64::from(day.num_days_from_monday()))
230                % 7;
231            today - chrono::Duration::days(if behind == 0 { 7 } else { behind })
232        }
233    }
234}
235
236fn period_bounds(
237    today: NaiveDate,
238    period: DatePeriod,
239    which: PeriodOccurrence,
240) -> Option<(NaiveDate, NaiveDate)> {
241    let step: i32 = match which {
242        PeriodOccurrence::This => 0,
243        PeriodOccurrence::Next => 1,
244        PeriodOccurrence::Last => -1,
245    };
246    match period {
247        DatePeriod::Week => {
248            let monday = today
249                - chrono::Duration::days(i64::from(today.weekday().num_days_from_monday()))
250                + chrono::Duration::days(i64::from(step) * 7);
251            Some((monday, monday + chrono::Duration::days(6)))
252        }
253        DatePeriod::Month => month_span(today, step, 1),
254        DatePeriod::Quarter => {
255            let first_month = (today.month0() / 3) * 3 + 1;
256            let anchor = NaiveDate::from_ymd_opt(today.year(), first_month, 1)?;
257            month_span(anchor, step * 3, 3)
258        }
259        DatePeriod::Year => {
260            let year = today.year() + step;
261            Some((
262                NaiveDate::from_ymd_opt(year, 1, 1)?,
263                NaiveDate::from_ymd_opt(year, 12, 31)?,
264            ))
265        }
266    }
267}
268
269/// The span of `length` months starting `offset` months from `date`'s month.
270fn month_span(date: NaiveDate, offset: i32, length: u32) -> Option<(NaiveDate, NaiveDate)> {
271    let first = NaiveDate::from_ymd_opt(date.year(), date.month(), 1)?;
272    let start = shift_months(first, offset.unsigned_abs(), offset >= 0)?;
273    let end = start.checked_add_months(Months::new(length))? - chrono::Duration::days(1);
274    Some((start, end))
275}
276
277impl From<DayOfWeek> for Weekday {
278    fn from(day: DayOfWeek) -> Self {
279        match day {
280            DayOfWeek::Monday => Self::Mon,
281            DayOfWeek::Tuesday => Self::Tue,
282            DayOfWeek::Wednesday => Self::Wed,
283            DayOfWeek::Thursday => Self::Thu,
284            DayOfWeek::Friday => Self::Fri,
285            DayOfWeek::Saturday => Self::Sat,
286            DayOfWeek::Sunday => Self::Sun,
287        }
288    }
289}
290
291/// An amount of money in minor units: `1050` euro cents is 10.50 EUR.
292#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
293#[serde(deny_unknown_fields)]
294pub struct Money {
295    /// The amount in the currency's minor unit.
296    pub minor: i64,
297    /// ISO 4217 code, upper case.
298    pub currency: String,
299}
300
301/// Why a decimal string is not an amount of money.
302#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
303#[error("{0}")]
304pub struct MoneyError(pub String);
305
306impl Money {
307    /// Parses `amount`, a decimal with a dot and no grouping, in `currency`.
308    ///
309    /// # Errors
310    ///
311    /// [`MoneyError`] for anything that is not such a decimal, for more decimals than
312    /// the currency has, or for an amount out of range.
313    pub fn parse(amount: &str, currency: &str) -> Result<Self, MoneyError> {
314        let currency = currency.trim().to_ascii_uppercase();
315        if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_uppercase()) {
316            return Err(MoneyError(format!("`{currency}` is not an ISO 4217 code")));
317        }
318        let exponent = minor_exponent(&currency);
319        let text = amount.trim();
320        let (negative, digits) = match text.strip_prefix('-') {
321            Some(rest) => (true, rest),
322            None => (false, text),
323        };
324        let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
325        let decimal = |part: &str| part.chars().all(|c| c.is_ascii_digit());
326        if whole.is_empty() || !decimal(whole) || !decimal(fraction) {
327            return Err(MoneyError(format!(
328                "`{amount}` is not a decimal written with digits and one dot"
329            )));
330        }
331        if fraction.len() > exponent as usize {
332            return Err(MoneyError(format!(
333                "`{amount}` has more decimals than {currency} allows ({exponent})"
334            )));
335        }
336        let scale = 10_i64.pow(exponent);
337        let out_of_range = || MoneyError(format!("`{amount}` is out of range"));
338        let whole: i64 = whole.parse().map_err(|_| out_of_range())?;
339        let padded = format!("{fraction:0<width$}", width = exponent as usize);
340        let fraction: i64 = if padded.is_empty() {
341            0
342        } else {
343            padded.parse().map_err(|_| out_of_range())?
344        };
345        let minor = whole
346            .checked_mul(scale)
347            .and_then(|minor| minor.checked_add(fraction))
348            .ok_or_else(out_of_range)?;
349        Ok(Self {
350            minor: if negative { -minor } else { minor },
351            currency,
352        })
353    }
354}
355
356/// Decimal places of a currency's minor unit; two unless the currency has none.
357fn minor_exponent(currency: &str) -> u32 {
358    match currency {
359        "JPY" | "KRW" | "VND" | "CLP" | "ISK" | "HUF" => 0,
360        "BHD" | "KWD" | "OMR" | "JOD" | "TND" => 3,
361        _ => 2,
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn day(y: i32, m: u32, d: u32) -> NaiveDate {
370        NaiveDate::from_ymd_opt(y, m, d).unwrap()
371    }
372
373    /// Tuesday, 14 November 2023.
374    fn today() -> NaiveDate {
375        day(2023, 11, 14)
376    }
377
378    fn eval(expr: DateExpr) -> NaiveDate {
379        expr.evaluate(today(), DateDirection::Future).unwrap()
380    }
381
382    #[test]
383    fn tomorrow_and_the_end_of_the_month_are_computed_from_today() {
384        let tomorrow = DateExpr::Relative {
385            unit: DateUnit::Day,
386            amount: 1,
387        };
388        assert_eq!(eval(tomorrow), day(2023, 11, 15));
389        let end_of_month = DateExpr::PeriodEnd {
390            period: DatePeriod::Month,
391            which: PeriodOccurrence::This,
392        };
393        assert_eq!(eval(end_of_month), day(2023, 11, 30));
394        let next_quarter_start = DateExpr::PeriodStart {
395            period: DatePeriod::Quarter,
396            which: PeriodOccurrence::Next,
397        };
398        assert_eq!(eval(next_quarter_start), day(2024, 1, 1));
399    }
400
401    #[test]
402    fn a_date_without_a_year_follows_the_arguments_direction() {
403        let march = DateExpr::Absolute {
404            year: None,
405            month: 3,
406            day: 1,
407        };
408        assert_eq!(
409            march.evaluate(today(), DateDirection::Future).unwrap(),
410            day(2024, 3, 1)
411        );
412        assert_eq!(
413            march.evaluate(today(), DateDirection::Past).unwrap(),
414            day(2023, 3, 1)
415        );
416        let december = DateExpr::Absolute {
417            year: None,
418            month: 12,
419            day: 1,
420        };
421        assert_eq!(
422            december.evaluate(today(), DateDirection::Past).unwrap(),
423            day(2022, 12, 1)
424        );
425        let impossible = DateExpr::Absolute {
426            year: Some(2023),
427            month: 4,
428            day: 31,
429        };
430        assert!(impossible.evaluate(today(), DateDirection::Any).is_err());
431    }
432
433    #[test]
434    fn weekdays_are_counted_from_the_week_today_is_in() {
435        let friday = |which| DateExpr::Weekday {
436            day: DayOfWeek::Friday,
437            which,
438        };
439        assert_eq!(eval(friday(WeekdayOccurrence::Coming)), day(2023, 11, 17));
440        assert_eq!(eval(friday(WeekdayOccurrence::NextWeek)), day(2023, 11, 24));
441        assert_eq!(eval(friday(WeekdayOccurrence::Previous)), day(2023, 11, 10));
442        let tuesday = DateExpr::Weekday {
443            day: DayOfWeek::Tuesday,
444            which: WeekdayOccurrence::Coming,
445        };
446        assert_eq!(eval(tuesday), day(2023, 11, 21), "coming never means today");
447        let month_end = DateExpr::Relative {
448            unit: DateUnit::Month,
449            amount: 1,
450        };
451        assert_eq!(
452            month_end
453                .evaluate(day(2024, 1, 31), DateDirection::Any)
454                .unwrap(),
455            day(2024, 2, 29),
456            "a month later is clamped to the month's length"
457        );
458    }
459
460    #[test]
461    fn amounts_are_parsed_into_minor_units_by_the_currencys_exponent() {
462        assert_eq!(
463            Money::parse("10.5", "eur").unwrap(),
464            Money {
465                minor: 1050,
466                currency: "EUR".into()
467            }
468        );
469        assert_eq!(Money::parse("1300", "EUR").unwrap().minor, 130_000);
470        assert_eq!(Money::parse("-2.01", "EUR").unwrap().minor, -201);
471        assert_eq!(Money::parse("1500", "JPY").unwrap().minor, 1500);
472        assert!(Money::parse("10.505", "EUR").is_err());
473        assert!(Money::parse("1,300", "EUR").is_err());
474        assert!(Money::parse("10", "euro").is_err());
475    }
476}