Skip to main content

hmrc_rates/
types.rs

1use core::fmt;
2
3use chrono::{Datelike, NaiveDate};
4
5/// A calendar month, the key of HMRC monthly rate tables.
6///
7/// ```
8/// use hmrc_rates::YearMonth;
9/// let m = YearMonth::new(2025, 8).unwrap();
10/// assert_eq!(m.to_string(), "2025-08");
11/// assert_eq!(m.next().month(), 9);
12/// ```
13#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
14pub struct YearMonth(i32); // year * 12 + (month - 1)
15
16impl YearMonth {
17    /// Returns `None` unless `month` is in `1..=12`.
18    pub fn new(year: i32, month: u32) -> Option<YearMonth> {
19        if !(1..=12).contains(&month) {
20            return None;
21        }
22        let key = year.checked_mul(12)?.checked_add(month as i32 - 1)?;
23        Some(YearMonth(key))
24    }
25
26    /// The calendar year.
27    pub fn year(self) -> i32 {
28        self.0.div_euclid(12)
29    }
30
31    /// The month number, `1..=12`.
32    pub fn month(self) -> u32 {
33        (self.0.rem_euclid(12) + 1) as u32
34    }
35
36    /// The following month (saturating at the representable maximum).
37    pub fn next(self) -> YearMonth {
38        YearMonth(self.0.saturating_add(1))
39    }
40
41    /// The preceding month (saturating at the representable minimum).
42    pub fn prev(self) -> YearMonth {
43        YearMonth(self.0.saturating_sub(1))
44    }
45
46    pub(crate) fn key(self) -> i32 {
47        self.0
48    }
49
50    pub(crate) fn from_key(key: i32) -> YearMonth {
51        YearMonth(key)
52    }
53}
54
55impl From<NaiveDate> for YearMonth {
56    fn from(date: NaiveDate) -> YearMonth {
57        YearMonth(date.year() * 12 + date.month() as i32 - 1)
58    }
59}
60
61impl fmt::Display for YearMonth {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(f, "{:04}-{:02}", self.year(), self.month())
64    }
65}
66
67/// The error returned when parsing a [`YearMonth`] from a string fails.
68#[derive(Copy, Clone, PartialEq, Eq, Debug)]
69pub struct ParseYearMonthError;
70
71impl fmt::Display for ParseYearMonthError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str("invalid month, expected YYYY-MM")
74    }
75}
76
77impl core::error::Error for ParseYearMonthError {}
78
79/// Parses `"YYYY-MM"` (the [`Display`](fmt::Display) form).
80impl core::str::FromStr for YearMonth {
81    type Err = ParseYearMonthError;
82
83    fn from_str(s: &str) -> Result<YearMonth, ParseYearMonthError> {
84        // rsplit: a leading minus sign belongs to a negative year
85        let (y, m) = s.rsplit_once('-').ok_or(ParseYearMonthError)?;
86        let parsed = YearMonth::new(
87            y.parse().map_err(|_| ParseYearMonthError)?,
88            m.parse().map_err(|_| ParseYearMonthError)?,
89        );
90        parsed.ok_or(ParseYearMonthError)
91    }
92}
93
94/// A spot/average rate period: HMRC publishes these only for years ending
95/// 31 March or 31 December, so other dates are unrepresentable.
96///
97/// ```
98/// use hmrc_rates::YearEnd;
99/// let ye = YearEnd::march(2026);
100/// assert!(ye.is_march());
101/// assert_eq!(ye.to_string(), "year ending 2026-03-31");
102/// ```
103#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
104pub struct YearEnd {
105    year: i32,
106    december: bool, // false = 31 March, true = 31 December; Mar < Dec within a year
107}
108
109impl YearEnd {
110    /// The year ending 31 March of `year`.
111    pub fn march(year: i32) -> YearEnd {
112        YearEnd {
113            year,
114            december: false,
115        }
116    }
117
118    /// The year ending 31 December of `year`.
119    pub fn december(year: i32) -> YearEnd {
120        YearEnd {
121            year,
122            december: true,
123        }
124    }
125
126    /// The period ending in `year_month` — `None` unless it is a March or December.
127    pub fn from_year_month(year_month: YearMonth) -> Option<YearEnd> {
128        match year_month.month() {
129            3 => Some(YearEnd::march(year_month.year())),
130            12 => Some(YearEnd::december(year_month.year())),
131            _ => None,
132        }
133    }
134
135    /// The calendar year the period ends in.
136    pub fn year(self) -> i32 {
137        self.year
138    }
139
140    /// `true` for a 31 March year end, `false` for 31 December.
141    pub fn is_march(self) -> bool {
142        !self.december
143    }
144
145    /// The month the period ends in (March or December of [`YearEnd::year`]).
146    pub fn end_year_month(self) -> YearMonth {
147        let month = if self.december { 12 } else { 3 };
148        // Saturating: absurd years stay panic-free and match no stored period
149        YearMonth(self.year.saturating_mul(12).saturating_add(month - 1))
150    }
151
152    pub(crate) fn key(self) -> i32 {
153        // Overflowing years map to a sentinel no stored period can have
154        self.year
155            .checked_mul(2)
156            .map_or(i32::MIN, |doubled| doubled + self.december as i32)
157    }
158
159    pub(crate) fn from_key(key: i32) -> YearEnd {
160        YearEnd {
161            year: key.div_euclid(2),
162            december: key.rem_euclid(2) == 1,
163        }
164    }
165}
166
167impl fmt::Display for YearEnd {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        let month = if self.december { 12 } else { 3 };
170        write!(f, "year ending {:04}-{:02}-31", self.year, month)
171    }
172}
173
174/// A three-letter currency code as published by HMRC.
175///
176/// Codes are HMRC's own, not always ISO 4217, e.g., Ecuador appears as `ECS`.
177/// Lookups accept plain `&str` (case-insensitive); the library returns `Currency`.
178#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
179pub struct Currency([u8; 3]);
180
181impl Currency {
182    /// Pound sterling, the base of every HMRC rate.
183    pub const GBP: Currency = Currency(*b"GBP");
184
185    /// The code as three uppercase ASCII letters.
186    pub fn as_str(&self) -> &str {
187        // Invariant: always three ASCII uppercase letters
188        core::str::from_utf8(&self.0).unwrap_or("???")
189    }
190
191    pub(crate) fn from_code(code: [u8; 3]) -> Currency {
192        Currency(code)
193    }
194
195    pub(crate) fn code(&self) -> [u8; 3] {
196        self.0
197    }
198
199    /// Trims and uppercases `s`; `None` unless the result is three ASCII letters.
200    pub(crate) fn normalize(s: &str) -> Option<[u8; 3]> {
201        let s = s.trim();
202        let bytes = s.as_bytes();
203        if bytes.len() != 3 || !bytes.iter().all(|b| b.is_ascii_alphabetic()) {
204            return None;
205        }
206        Some([
207            bytes[0].to_ascii_uppercase(),
208            bytes[1].to_ascii_uppercase(),
209            bytes[2].to_ascii_uppercase(),
210        ])
211    }
212}
213
214impl fmt::Display for Currency {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        f.write_str(self.as_str())
217    }
218}
219
220/// The four rate series HMRC has published.
221#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
222#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
223#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
224#[non_exhaustive]
225pub enum RateType {
226    /// Monthly customs/VAT rates (2014-02 onwards).
227    Monthly,
228    /// Spot rates on 31 March / 31 December (2010-12 onwards, major currencies).
229    Spot,
230    /// Yearly average rates to 31 March / 31 December (2010-12 onwards).
231    Average,
232    /// Weekly amendment series (2014-01 to 2016-04, then discontinued).
233    Weekly,
234}
235
236impl fmt::Display for RateType {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        f.write_str(match self {
239            RateType::Monthly => "monthly",
240            RateType::Spot => "spot",
241            RateType::Average => "average",
242            RateType::Weekly => "weekly",
243        })
244    }
245}
246
247/// The period a [`Rate`](crate::Rate) or table applies to.
248#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
249#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
250#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
251#[non_exhaustive]
252pub enum Period {
253    /// A calendar month (monthly series).
254    YearMonth(YearMonth),
255    /// A year ending 31 March or 31 December (spot and average series).
256    YearEnd(YearEnd),
257    /// An inclusive weekly-amendment validity range.
258    Week { start: NaiveDate, end: NaiveDate },
259}
260
261impl fmt::Display for Period {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            Period::YearMonth(m) => m.fmt(f),
265            Period::YearEnd(ye) => ye.fmt(f),
266            Period::Week { start, end } => write!(f, "week {start} to {end}"),
267        }
268    }
269}
270
271// Compact string forms: YearMonth "2026-07", YearEnd "2026-03"/"2025-12", Currency "USD"
272#[cfg(feature = "serde")]
273mod serde_impls {
274    use super::{Currency, YearEnd, YearMonth};
275    use alloc::format;
276    use alloc::string::String;
277    use serde::de::Error as _;
278    use serde::{Deserialize, Deserializer, Serialize, Serializer};
279
280    impl Serialize for YearMonth {
281        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
282            serializer.collect_str(self)
283        }
284    }
285
286    impl<'de> Deserialize<'de> for YearMonth {
287        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<YearMonth, D::Error> {
288            let s = String::deserialize(deserializer)?;
289            s.parse()
290                .map_err(|_| D::Error::custom(format!("invalid month '{s}', expected YYYY-MM")))
291        }
292    }
293
294    impl Serialize for YearEnd {
295        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
296            let month = if self.is_march() { 3 } else { 12 };
297            serializer.collect_str(&format_args!("{:04}-{:02}", self.year(), month))
298        }
299    }
300
301    impl<'de> Deserialize<'de> for YearEnd {
302        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<YearEnd, D::Error> {
303            let year_month = YearMonth::deserialize(deserializer)?;
304            YearEnd::from_year_month(year_month).ok_or_else(|| {
305                D::Error::custom(format!(
306                    "invalid year end '{year_month}', expected March or December"
307                ))
308            })
309        }
310    }
311
312    impl Serialize for Currency {
313        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
314            serializer.serialize_str(self.as_str())
315        }
316    }
317
318    impl<'de> Deserialize<'de> for Currency {
319        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Currency, D::Error> {
320            let s = String::deserialize(deserializer)?;
321            Currency::normalize(&s)
322                .map(Currency::from_code)
323                .ok_or_else(|| D::Error::custom(format!("invalid currency code '{s}'")))
324        }
325    }
326}
327
328#[cfg(test)]
329#[allow(clippy::unwrap_used)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn month_roundtrip_and_arithmetic() {
335        let m = YearMonth::new(2025, 1).unwrap();
336        assert_eq!((m.year(), m.month()), (2025, 1));
337        assert_eq!(m.prev(), YearMonth::new(2024, 12).unwrap());
338        assert_eq!(m.next(), YearMonth::new(2025, 2).unwrap());
339        assert_eq!(
340            YearMonth::new(2025, 12).unwrap().next(),
341            YearMonth::new(2026, 1).unwrap()
342        );
343        assert!(YearMonth::new(2025, 0).is_none());
344        assert!(YearMonth::new(2025, 13).is_none());
345        assert_eq!(m.to_string(), "2025-01");
346    }
347
348    #[test]
349    fn month_from_date() {
350        let date = NaiveDate::from_ymd_opt(2025, 8, 31).unwrap();
351        assert_eq!(YearMonth::from(date), YearMonth::new(2025, 8).unwrap());
352    }
353
354    #[test]
355    fn year_end_ordering_and_display() {
356        assert!(YearEnd::march(2025) < YearEnd::december(2025));
357        assert!(YearEnd::december(2024) < YearEnd::march(2025));
358        assert_eq!(
359            YearEnd::march(2026).end_year_month(),
360            YearMonth::new(2026, 3).unwrap()
361        );
362        assert_eq!(
363            YearEnd::december(2025).to_string(),
364            "year ending 2025-12-31"
365        );
366        assert_eq!(
367            YearEnd::from_key(YearEnd::march(2026).key()),
368            YearEnd::march(2026)
369        );
370    }
371
372    #[test]
373    fn currency_normalization() {
374        assert_eq!(Currency::normalize(" usd "), Some(*b"USD"));
375        assert_eq!(Currency::normalize("EuR"), Some(*b"EUR"));
376        assert_eq!(Currency::normalize(""), None);
377        assert_eq!(Currency::normalize("US"), None);
378        assert_eq!(Currency::normalize("USDX"), None);
379        assert_eq!(Currency::normalize("U5D"), None);
380        assert_eq!(Currency::GBP.as_str(), "GBP");
381    }
382}