1use chrono::{Datelike, Days, Months, NaiveDate, Weekday};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
12#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
13pub enum DateExpr {
14 Absolute {
16 #[serde(default)]
18 year: Option<i32>,
19 month: u32,
21 day: u32,
23 },
24 Relative {
26 unit: DateUnit,
28 amount: i32,
30 },
31 Weekday {
33 day: DayOfWeek,
35 which: WeekdayOccurrence,
37 },
38 PeriodEnd {
40 period: DatePeriod,
42 which: PeriodOccurrence,
44 },
45 PeriodStart {
47 period: DatePeriod,
49 which: PeriodOccurrence,
51 },
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
56#[serde(rename_all = "snake_case")]
57pub enum DateUnit {
58 Day,
60 Week,
62 Month,
64 Year,
66}
67
68#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(rename_all = "snake_case")]
85pub enum WeekdayOccurrence {
86 Coming,
88 Previous,
90 ThisWeek,
92 NextWeek,
94 LastWeek,
96}
97
98#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum DateDirection {
123 #[default]
125 Any,
126 Future,
128 Past,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
134#[error("{0}")]
135pub struct DateError(pub String);
136
137impl DateExpr {
138 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
269fn 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
293#[serde(deny_unknown_fields)]
294pub struct Money {
295 pub minor: i64,
297 pub currency: String,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
303#[error("{0}")]
304pub struct MoneyError(pub String);
305
306impl Money {
307 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(¤cy);
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
356fn 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 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}