//! Time helpers and the `MonthYear` type used for card expiries. use serde::{Deserialize, Serialize}; use crate::error::{RelicarioError, Result}; /// Current Unix timestamp in seconds. pub fn now_unix() -> i64 { chrono::Utc::now().timestamp() } /// Month + year (1-12 / e.g. 2026). Used for card expiries. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct MonthYear { pub month: u8, pub year: u16, } impl MonthYear { pub fn new(month: u8, year: u16) -> std::result::Result { if !(1..=12).contains(&month) { return Err("month must be 1..=12"); } if !(2000..=2099).contains(&year) { return Err("year must be 2000..=2099"); } Ok(Self { month, year }) } /// Parse a card-expiry string. Accepts `MM/YYYY`, `MM-YYYY`, and `MM/YY` /// (two-digit year is taken as 20YY). pub fn parse(s: &str) -> Result { let invalid = |detail: String| RelicarioError::InvalidMonthYear(detail); let (m_str, y_str) = s .split_once(['/', '-']) .ok_or_else(|| invalid(format!("expected MM/YYYY, got {s:?}")))?; let month: u8 = m_str .parse() .map_err(|_| invalid(format!("bad month {m_str:?}")))?; let year: u16 = if y_str.len() == 2 { 2000 + y_str .parse::() .map_err(|_| invalid(format!("bad 2-digit year {y_str:?}")))? } else { y_str .parse() .map_err(|_| invalid(format!("bad year {y_str:?}")))? }; Self::new(month, year).map_err(|e| invalid(e.into())) } } #[cfg(test)] mod tests { use super::*; #[test] fn now_unix_is_positive_and_recent() { let t = now_unix(); assert!(t > 1_700_000_000); // after late 2023 assert!(t < 4_000_000_000); // before 2096 } #[test] fn month_year_constructor_rejects_bad_month() { assert!(MonthYear::new(0, 2026).is_err()); assert!(MonthYear::new(13, 2026).is_err()); assert!(MonthYear::new(1, 2026).is_ok()); assert!(MonthYear::new(12, 2026).is_ok()); } #[test] fn month_year_constructor_rejects_bad_year() { assert!(MonthYear::new(1, 1999).is_err()); assert!(MonthYear::new(1, 2100).is_err()); assert!(MonthYear::new(1, 2000).is_ok()); assert!(MonthYear::new(1, 2099).is_ok()); } #[test] fn month_year_round_trips_through_json() { let my = MonthYear::new(7, 2030).unwrap(); let json = serde_json::to_string(&my).unwrap(); let parsed: MonthYear = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, my); } #[test] fn parse_accepts_mm_slash_yyyy_and_mm_dash_yyyy() { assert_eq!(MonthYear::parse("01/2026").unwrap(), MonthYear::new(1, 2026).unwrap()); assert_eq!(MonthYear::parse("12/2099").unwrap(), MonthYear::new(12, 2099).unwrap()); assert_eq!(MonthYear::parse("07-2030").unwrap(), MonthYear::new(7, 2030).unwrap()); } #[test] fn parse_accepts_mm_slash_yy() { assert_eq!(MonthYear::parse("01/26").unwrap(), MonthYear::new(1, 2026).unwrap()); assert_eq!(MonthYear::parse("12/99").unwrap(), MonthYear::new(12, 2099).unwrap()); } #[test] fn parse_rejects_malformed() { assert!(matches!( MonthYear::parse("garbage"), Err(RelicarioError::InvalidMonthYear(_)) )); assert!(MonthYear::parse("13/2026").is_err()); // bad month assert!(MonthYear::parse("01/1999").is_err()); // pre-2000 assert!(MonthYear::parse("01/2100").is_err()); // post-2099 assert!(MonthYear::parse("/2026").is_err()); // empty month assert!(MonthYear::parse("01/").is_err()); // empty year } }