hmrc-rates 0.3.1

HMRC exchange rates (monthly, spot, yearly average, weekly) with bundled history and GBP conversion.
Documentation
use core::fmt;

use chrono::{Datelike, NaiveDate};

/// A calendar month, the key of HMRC monthly rate tables.
///
/// ```
/// use hmrc_rates::YearMonth;
/// let m = YearMonth::new(2025, 8).unwrap();
/// assert_eq!(m.to_string(), "2025-08");
/// assert_eq!(m.next().month(), 9);
/// ```
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct YearMonth(i32); // year * 12 + (month - 1)

impl YearMonth {
    /// Returns `None` unless `month` is in `1..=12`.
    pub fn new(year: i32, month: u32) -> Option<YearMonth> {
        if !(1..=12).contains(&month) {
            return None;
        }
        let key = year.checked_mul(12)?.checked_add(month as i32 - 1)?;
        Some(YearMonth(key))
    }

    /// The calendar year.
    pub fn year(self) -> i32 {
        self.0.div_euclid(12)
    }

    /// The month number, `1..=12`.
    pub fn month(self) -> u32 {
        (self.0.rem_euclid(12) + 1) as u32
    }

    /// The following month (saturating at the representable maximum).
    pub fn next(self) -> YearMonth {
        YearMonth(self.0.saturating_add(1))
    }

    /// The preceding month (saturating at the representable minimum).
    pub fn prev(self) -> YearMonth {
        YearMonth(self.0.saturating_sub(1))
    }

    pub(crate) fn key(self) -> i32 {
        self.0
    }

    pub(crate) fn from_key(key: i32) -> YearMonth {
        YearMonth(key)
    }
}

impl From<NaiveDate> for YearMonth {
    fn from(date: NaiveDate) -> YearMonth {
        YearMonth(date.year() * 12 + date.month() as i32 - 1)
    }
}

impl fmt::Display for YearMonth {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:04}-{:02}", self.year(), self.month())
    }
}

/// The error returned when parsing a [`YearMonth`] from a string fails.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct ParseYearMonthError;

impl fmt::Display for ParseYearMonthError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("invalid month, expected YYYY-MM")
    }
}

impl core::error::Error for ParseYearMonthError {}

/// Parses `"YYYY-MM"` (the [`Display`](fmt::Display) form).
impl core::str::FromStr for YearMonth {
    type Err = ParseYearMonthError;

    fn from_str(s: &str) -> Result<YearMonth, ParseYearMonthError> {
        // rsplit: a leading minus sign belongs to a negative year
        let (y, m) = s.rsplit_once('-').ok_or(ParseYearMonthError)?;
        let parsed = YearMonth::new(
            y.parse().map_err(|_| ParseYearMonthError)?,
            m.parse().map_err(|_| ParseYearMonthError)?,
        );
        parsed.ok_or(ParseYearMonthError)
    }
}

/// A spot/average rate period: HMRC publishes these only for years ending
/// 31 March or 31 December, so other dates are unrepresentable.
///
/// ```
/// use hmrc_rates::YearEnd;
/// let ye = YearEnd::march(2026);
/// assert!(ye.is_march());
/// assert_eq!(ye.to_string(), "year ending 2026-03-31");
/// ```
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct YearEnd {
    year: i32,
    december: bool, // false = 31 March, true = 31 December; Mar < Dec within a year
}

impl YearEnd {
    /// The year ending 31 March of `year`.
    pub fn march(year: i32) -> YearEnd {
        YearEnd {
            year,
            december: false,
        }
    }

    /// The year ending 31 December of `year`.
    pub fn december(year: i32) -> YearEnd {
        YearEnd {
            year,
            december: true,
        }
    }

    /// The period ending in `year_month` — `None` unless it is a March or December.
    pub fn from_year_month(year_month: YearMonth) -> Option<YearEnd> {
        match year_month.month() {
            3 => Some(YearEnd::march(year_month.year())),
            12 => Some(YearEnd::december(year_month.year())),
            _ => None,
        }
    }

    /// The calendar year the period ends in.
    pub fn year(self) -> i32 {
        self.year
    }

    /// `true` for a 31 March year end, `false` for 31 December.
    pub fn is_march(self) -> bool {
        !self.december
    }

    /// The month the period ends in (March or December of [`YearEnd::year`]).
    pub fn end_year_month(self) -> YearMonth {
        let month = if self.december { 12 } else { 3 };
        // Saturating: absurd years stay panic-free and match no stored period
        YearMonth(self.year.saturating_mul(12).saturating_add(month - 1))
    }

    pub(crate) fn key(self) -> i32 {
        // Overflowing years map to a sentinel no stored period can have
        self.year
            .checked_mul(2)
            .map_or(i32::MIN, |doubled| doubled + self.december as i32)
    }

    pub(crate) fn from_key(key: i32) -> YearEnd {
        YearEnd {
            year: key.div_euclid(2),
            december: key.rem_euclid(2) == 1,
        }
    }
}

impl fmt::Display for YearEnd {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let month = if self.december { 12 } else { 3 };
        write!(f, "year ending {:04}-{:02}-31", self.year, month)
    }
}

/// A three-letter currency code as published by HMRC.
///
/// Codes are HMRC's own, not always ISO 4217, e.g., Ecuador appears as `ECS`.
/// Lookups accept plain `&str` (case-insensitive); the library returns `Currency`.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Currency([u8; 3]);

impl Currency {
    /// Pound sterling, the base of every HMRC rate.
    pub const GBP: Currency = Currency(*b"GBP");

    /// The code as three uppercase ASCII letters.
    pub fn as_str(&self) -> &str {
        // Invariant: always three ASCII uppercase letters
        core::str::from_utf8(&self.0).unwrap_or("???")
    }

    pub(crate) fn from_code(code: [u8; 3]) -> Currency {
        Currency(code)
    }

    pub(crate) fn code(&self) -> [u8; 3] {
        self.0
    }

    /// Trims and uppercases `s`; `None` unless the result is three ASCII letters.
    pub(crate) fn normalize(s: &str) -> Option<[u8; 3]> {
        let s = s.trim();
        let bytes = s.as_bytes();
        if bytes.len() != 3 || !bytes.iter().all(|b| b.is_ascii_alphabetic()) {
            return None;
        }
        Some([
            bytes[0].to_ascii_uppercase(),
            bytes[1].to_ascii_uppercase(),
            bytes[2].to_ascii_uppercase(),
        ])
    }
}

impl fmt::Display for Currency {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The four rate series HMRC has published.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
#[non_exhaustive]
pub enum RateType {
    /// Monthly customs/VAT rates (2014-02 onwards).
    Monthly,
    /// Spot rates on 31 March / 31 December (2010-12 onwards, major currencies).
    Spot,
    /// Yearly average rates to 31 March / 31 December (2010-12 onwards).
    Average,
    /// Weekly amendment series (2014-01 to 2016-04, then discontinued).
    Weekly,
}

impl fmt::Display for RateType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            RateType::Monthly => "monthly",
            RateType::Spot => "spot",
            RateType::Average => "average",
            RateType::Weekly => "weekly",
        })
    }
}

/// The period a [`Rate`](crate::Rate) or table applies to.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[non_exhaustive]
pub enum Period {
    /// A calendar month (monthly series).
    YearMonth(YearMonth),
    /// A year ending 31 March or 31 December (spot and average series).
    YearEnd(YearEnd),
    /// An inclusive weekly-amendment validity range.
    Week { start: NaiveDate, end: NaiveDate },
}

impl fmt::Display for Period {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Period::YearMonth(m) => m.fmt(f),
            Period::YearEnd(ye) => ye.fmt(f),
            Period::Week { start, end } => write!(f, "week {start} to {end}"),
        }
    }
}

// Compact string forms: YearMonth "2026-07", YearEnd "2026-03"/"2025-12", Currency "USD"
#[cfg(feature = "serde")]
mod serde_impls {
    use super::{Currency, YearEnd, YearMonth};
    use alloc::format;
    use alloc::string::String;
    use serde::de::Error as _;
    use serde::{Deserialize, Deserializer, Serialize, Serializer};

    impl Serialize for YearMonth {
        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
            serializer.collect_str(self)
        }
    }

    impl<'de> Deserialize<'de> for YearMonth {
        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<YearMonth, D::Error> {
            let s = String::deserialize(deserializer)?;
            s.parse()
                .map_err(|_| D::Error::custom(format!("invalid month '{s}', expected YYYY-MM")))
        }
    }

    impl Serialize for YearEnd {
        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
            let month = if self.is_march() { 3 } else { 12 };
            serializer.collect_str(&format_args!("{:04}-{:02}", self.year(), month))
        }
    }

    impl<'de> Deserialize<'de> for YearEnd {
        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<YearEnd, D::Error> {
            let year_month = YearMonth::deserialize(deserializer)?;
            YearEnd::from_year_month(year_month).ok_or_else(|| {
                D::Error::custom(format!(
                    "invalid year end '{year_month}', expected March or December"
                ))
            })
        }
    }

    impl Serialize for Currency {
        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
            serializer.serialize_str(self.as_str())
        }
    }

    impl<'de> Deserialize<'de> for Currency {
        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Currency, D::Error> {
            let s = String::deserialize(deserializer)?;
            Currency::normalize(&s)
                .map(Currency::from_code)
                .ok_or_else(|| D::Error::custom(format!("invalid currency code '{s}'")))
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn month_roundtrip_and_arithmetic() {
        let m = YearMonth::new(2025, 1).unwrap();
        assert_eq!((m.year(), m.month()), (2025, 1));
        assert_eq!(m.prev(), YearMonth::new(2024, 12).unwrap());
        assert_eq!(m.next(), YearMonth::new(2025, 2).unwrap());
        assert_eq!(
            YearMonth::new(2025, 12).unwrap().next(),
            YearMonth::new(2026, 1).unwrap()
        );
        assert!(YearMonth::new(2025, 0).is_none());
        assert!(YearMonth::new(2025, 13).is_none());
        assert_eq!(m.to_string(), "2025-01");
    }

    #[test]
    fn month_from_date() {
        let date = NaiveDate::from_ymd_opt(2025, 8, 31).unwrap();
        assert_eq!(YearMonth::from(date), YearMonth::new(2025, 8).unwrap());
    }

    #[test]
    fn year_end_ordering_and_display() {
        assert!(YearEnd::march(2025) < YearEnd::december(2025));
        assert!(YearEnd::december(2024) < YearEnd::march(2025));
        assert_eq!(
            YearEnd::march(2026).end_year_month(),
            YearMonth::new(2026, 3).unwrap()
        );
        assert_eq!(
            YearEnd::december(2025).to_string(),
            "year ending 2025-12-31"
        );
        assert_eq!(
            YearEnd::from_key(YearEnd::march(2026).key()),
            YearEnd::march(2026)
        );
    }

    #[test]
    fn currency_normalization() {
        assert_eq!(Currency::normalize(" usd "), Some(*b"USD"));
        assert_eq!(Currency::normalize("EuR"), Some(*b"EUR"));
        assert_eq!(Currency::normalize(""), None);
        assert_eq!(Currency::normalize("US"), None);
        assert_eq!(Currency::normalize("USDX"), None);
        assert_eq!(Currency::normalize("U5D"), None);
        assert_eq!(Currency::GBP.as_str(), "GBP");
    }
}