Skip to main content

hmrc_rates/
rates.rs

1use alloc::vec::Vec;
2
3use chrono::{Datelike, NaiveDate};
4use rust_decimal::Decimal;
5
6use crate::error::LookupError;
7use crate::rate::Rate;
8use crate::store::{self, Entry, Series, WeekIdx, Weeks};
9use crate::types::{Currency, Period, RateType, YearEnd, YearMonth};
10
11// chrono counts day 1 = 0001-01-01; our day 0 = 1970-01-01
12const CE_EPOCH_OFFSET: i32 = 719_163;
13
14fn date_to_day(date: NaiveDate) -> i32 {
15    date.num_days_from_ce() - CE_EPOCH_OFFSET
16}
17
18fn day_to_date(day: i32) -> Option<NaiveDate> {
19    NaiveDate::from_num_days_from_ce_opt(day.checked_add(CE_EPOCH_OFFSET)?)
20}
21
22/// The validity range of a weekly index row as a `Period`.
23fn week_period(week: &WeekIdx) -> Option<Period> {
24    Some(Period::Week {
25        start: day_to_date(week.start_day)?,
26        end: day_to_date(week.end_day)?,
27    })
28}
29
30/// £1 = £1 for any period, published or not.
31fn gbp_identity(code: &str, period: Period) -> Option<Rate> {
32    (Currency::normalize(code) == Some(Currency::GBP.code()))
33        .then(|| Rate::new(Decimal::ONE, Currency::GBP, period))
34}
35
36/// All HMRC rate tables: bundled data plus (with the `http` feature) fetched periods.
37///
38/// `Send + Sync`: cloning is cheap, bundled data is shared statics.
39///
40/// Start with [`Rates::new`].
41#[derive(Clone)]
42pub struct Rates {
43    monthly: Series,
44    spot: Series,
45    average: Series,
46    weeks: Weeks,
47}
48
49impl core::fmt::Debug for Rates {
50    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
51        f.debug_struct("Rates")
52            .field("months", &self.monthly.keys().len())
53            .field("spot_periods", &self.spot.keys().len())
54            .field("average_periods", &self.average.keys().len())
55            .field("weeks", &self.weeks.index().len())
56            .finish()
57    }
58}
59
60#[cfg(feature = "bundled")]
61impl Default for Rates {
62    fn default() -> Rates {
63        Rates::new()
64    }
65}
66
67impl Rates {
68    /// The bundled dataset.
69    /// Infallible and effectively free.
70    /// The tables live in the binary's read-only data, nothing is parsed or allocated.
71    ///
72    /// # Examples
73    ///
74    /// ```
75    /// use hmrc_rates::Rates;
76    ///
77    /// let rates = Rates::new();
78    /// assert!(rates.months().count() > 100);
79    /// ```
80    #[cfg(feature = "bundled")]
81    pub fn new() -> Rates {
82        Rates {
83            monthly: Series::new(crate::bundled::MONTHLY),
84            spot: Series::new(crate::bundled::SPOT),
85            average: Series::new(crate::bundled::AVERAGE),
86            weeks: Weeks::new(crate::bundled::WEEKLY),
87        }
88    }
89
90    /// A `Rates` with no data at all.
91    #[cfg(test)]
92    pub(crate) fn empty() -> Rates {
93        Rates {
94            monthly: Series::new(store::EMPTY_SERIES),
95            spot: Series::new(store::EMPTY_SERIES),
96            average: Series::new(store::EMPTY_SERIES),
97            weeks: Weeks::new(store::EMPTY_WEEKS),
98        }
99    }
100
101    #[cfg(feature = "http")]
102    pub(crate) fn set_period(&mut self, table: RateType, key: i32, entries: Vec<Entry>) {
103        match table {
104            RateType::Monthly => self.monthly.set(key, entries),
105            RateType::Spot => self.spot.set(key, entries),
106            RateType::Average => self.average.set(key, entries),
107            _ => {}
108        }
109    }
110
111    /// The monthly rate for `code`, strictly for that month.
112    ///
113    /// Accepts anything convertible to [`YearMonth`], including `chrono::NaiveDate`.
114    /// `"GBP"` (any case) returns the identity rate for any month.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use hmrc_rates::Rates;
120    /// use rust_decimal::Decimal;
121    ///
122    /// let rates = Rates::new();
123    /// let date = chrono::NaiveDate::from_ymd_opt(2025, 8, 15).unwrap();
124    /// let rate = rates.monthly_rate("USD", date)?;
125    /// let gbp = rate.to_gbp(Decimal::from(100));
126    /// # Ok::<(), hmrc_rates::LookupError>(())
127    /// ```
128    pub fn monthly_rate(
129        &self,
130        code: &str,
131        year_month: impl Into<YearMonth>,
132    ) -> Result<Rate, LookupError> {
133        self.monthly_rate_or_earlier(code, year_month, 0)
134    }
135
136    /// Like [`Rates::monthly_rate`], but walks back to the nearest earlier
137    /// published month, at most `max_months_back` steps.
138    ///
139    /// This is the crate's only fallback, and it is opt-in.
140    /// [`Rate::period`] reveals which month was actually used.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use hmrc_rates::{Period, Rates};
146    ///
147    /// let rates = Rates::new();
148    /// let next = rates.months().next_back().unwrap().next(); // not published yet
149    /// assert!(rates.monthly_rate("USD", next).is_err()); // strict lookup fails
150    /// let rate = rates.monthly_rate_or_earlier("USD", next, 1)?;
151    /// assert_ne!(rate.period(), Period::YearMonth(next)); // the substitution is visible
152    /// # Ok::<(), hmrc_rates::LookupError>(())
153    /// ```
154    pub fn monthly_rate_or_earlier(
155        &self,
156        code: &str,
157        year_month: impl Into<YearMonth>,
158        max_months_back: u32,
159    ) -> Result<Rate, LookupError> {
160        let requested = year_month.into();
161        // GBP resolves for the requested month itself — no substitution
162        if let Some(rate) = gbp_identity(code, Period::YearMonth(requested)) {
163            return Ok(rate);
164        }
165        let mut candidate = requested;
166        for _ in 0..=max_months_back {
167            if self.monthly.table(candidate.key()).is_some() {
168                return self.monthly(candidate)?.rate(code);
169            }
170            candidate = candidate.prev();
171        }
172        Err(self.period_missing(RateType::Monthly, Period::YearMonth(requested)))
173    }
174
175    /// The whole monthly table for one month.
176    pub fn monthly(&self, year_month: impl Into<YearMonth>) -> Result<Table<'_>, LookupError> {
177        let year_month = year_month.into();
178        let period = Period::YearMonth(year_month);
179        match self.monthly.table(year_month.key()) {
180            Some(entries) => Ok(Table {
181                rate_type: RateType::Monthly,
182                period,
183                entries,
184                known: Known::Series(&self.monthly),
185            }),
186            None => Err(self.period_missing(RateType::Monthly, period)),
187        }
188    }
189
190    /// The spot table for a 31 March / 31 December period.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use hmrc_rates::{Rates, YearEnd};
196    ///
197    /// let rates = Rates::new();
198    /// let usd = rates.spot(YearEnd::december(2024))?.rate("USD")?;
199    /// # Ok::<(), hmrc_rates::LookupError>(())
200    /// ```
201    pub fn spot(&self, period: YearEnd) -> Result<Table<'_>, LookupError> {
202        self.year_end_table(&self.spot, RateType::Spot, period)
203    }
204
205    /// The yearly-average table for a 31 March / 31 December period.
206    ///
207    /// # Examples
208    ///
209    /// ```
210    /// use hmrc_rates::{Rates, YearEnd};
211    ///
212    /// let rates = Rates::new();
213    /// // Self Assessment style: the average for the year to 31 March 2025.
214    /// let eur = rates.average(YearEnd::march(2025))?.rate("EUR")?;
215    /// # Ok::<(), hmrc_rates::LookupError>(())
216    /// ```
217    pub fn average(&self, period: YearEnd) -> Result<Table<'_>, LookupError> {
218        self.year_end_table(&self.average, RateType::Average, period)
219    }
220
221    /// The weekly-amendment table whose validity range contains `date`.
222    ///
223    /// Weekly files list only the currencies HMRC amended that week
224    /// (series ran 2014-01 to 2016-04, then was discontinued).
225    ///
226    /// # Examples
227    ///
228    /// ```
229    /// use hmrc_rates::Rates;
230    ///
231    /// let rates = Rates::new();
232    /// let date = chrono::NaiveDate::from_ymd_opt(2014, 1, 10).unwrap();
233    /// let lira = rates.weekly(date)?.rate("TRY")?;
234    /// # Ok::<(), hmrc_rates::LookupError>(())
235    /// ```
236    pub fn weekly(&self, date: NaiveDate) -> Result<Table<'_>, LookupError> {
237        let day = date_to_day(date);
238        if let Some((week, entries)) = self.weeks.containing(day) {
239            if let Some(period) = week_period(&week) {
240                return Ok(Table {
241                    rate_type: RateType::Weekly,
242                    period,
243                    entries,
244                    known: Known::Weeks(&self.weeks),
245                });
246            }
247        }
248        Err(LookupError::PeriodNotAvailable {
249            table: RateType::Weekly,
250            period: Period::Week {
251                start: date,
252                end: date,
253            },
254            available: self.available(RateType::Weekly),
255        })
256    }
257
258    /// All published months, ascending.
259    pub fn months(&self) -> impl DoubleEndedIterator<Item = YearMonth> + use<'_> {
260        self.monthly.keys().into_iter().map(YearMonth::from_key)
261    }
262
263    /// All published spot periods, ascending.
264    pub fn spot_periods(&self) -> impl DoubleEndedIterator<Item = YearEnd> + use<'_> {
265        self.spot.keys().into_iter().map(YearEnd::from_key)
266    }
267
268    /// All published yearly-average periods, ascending.
269    pub fn average_periods(&self) -> impl DoubleEndedIterator<Item = YearEnd> + use<'_> {
270        self.average.keys().into_iter().map(YearEnd::from_key)
271    }
272
273    /// All weekly-amendment validity ranges, ascending, as [`Period::Week`] items.
274    pub fn weeks(&self) -> impl DoubleEndedIterator<Item = Period> + use<'_> {
275        self.weeks.index().iter().filter_map(week_period)
276    }
277
278    /// Every currency that appears anywhere in the given series, ascending.
279    pub fn currencies(&self, table: RateType) -> impl Iterator<Item = Currency> + use<'_> {
280        let codes = match table {
281            RateType::Monthly => self.monthly.codes(),
282            RateType::Spot => self.spot.codes(),
283            RateType::Average => self.average.codes(),
284            RateType::Weekly => self.weekly_codes(),
285        };
286        codes.into_iter().map(Currency::from_code)
287    }
288
289    fn weekly_codes(&self) -> Vec<[u8; 3]> {
290        let mut codes: Vec<[u8; 3]> = self.weeks.arena().iter().map(|e| e.code).collect();
291        codes.sort_unstable();
292        codes.dedup();
293        codes
294    }
295
296    fn year_end_table<'a>(
297        &'a self,
298        series: &'a Series,
299        rate_type: RateType,
300        period: YearEnd,
301    ) -> Result<Table<'a>, LookupError> {
302        match series.table(period.key()) {
303            Some(entries) => Ok(Table {
304                rate_type,
305                period: Period::YearEnd(period),
306                entries,
307                known: Known::Series(series),
308            }),
309            None => Err(self.period_missing(rate_type, Period::YearEnd(period))),
310        }
311    }
312
313    /// The loaded range of a series, for `PeriodNotAvailable` messages.
314    fn available(&self, table: RateType) -> Option<(Period, Period)> {
315        match table {
316            RateType::Monthly => self.monthly.first_last().map(|(f, l)| {
317                (
318                    Period::YearMonth(YearMonth::from_key(f)),
319                    Period::YearMonth(YearMonth::from_key(l)),
320                )
321            }),
322            RateType::Spot | RateType::Average => {
323                let series = if table == RateType::Spot {
324                    &self.spot
325                } else {
326                    &self.average
327                };
328                series.first_last().map(|(f, l)| {
329                    (
330                        Period::YearEnd(YearEnd::from_key(f)),
331                        Period::YearEnd(YearEnd::from_key(l)),
332                    )
333                })
334            }
335            RateType::Weekly => {
336                let idx = self.weeks.index();
337                Some((week_period(idx.first()?)?, week_period(idx.last()?)?))
338            }
339        }
340    }
341
342    fn period_missing(&self, table: RateType, period: Period) -> LookupError {
343        LookupError::PeriodNotAvailable {
344            table,
345            period,
346            available: self.available(table),
347        }
348    }
349}
350
351#[derive(Copy, Clone)]
352enum Known<'a> {
353    Series(&'a Series),
354    Weeks(&'a Weeks),
355}
356
357impl Known<'_> {
358    fn knows(&self, code: [u8; 3]) -> bool {
359        match self {
360            Known::Series(s) => s.knows(code),
361            Known::Weeks(w) => w.knows(code),
362        }
363    }
364}
365
366/// A borrowed view of one period's table — resolve once, convert many times.
367#[derive(Copy, Clone)]
368pub struct Table<'a> {
369    rate_type: RateType,
370    period: Period,
371    entries: &'a [Entry],
372    known: Known<'a>,
373}
374
375impl core::fmt::Debug for Table<'_> {
376    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
377        f.debug_struct("Table")
378            .field("rate_type", &self.rate_type)
379            .field("period", &self.period)
380            .field("len", &self.entries.len())
381            .finish()
382    }
383}
384
385impl<'a> Table<'a> {
386    /// The period this table was published for.
387    pub fn period(&self) -> Period {
388        self.period
389    }
390
391    /// The series this table belongs to.
392    pub fn rate_type(&self) -> RateType {
393        self.rate_type
394    }
395
396    /// The rate for `code` in this period. `"GBP"` always resolves to the identity rate.
397    ///
398    /// Errors distinguish a code the series has never published
399    /// ([`LookupError::UnknownCurrency`]) from one merely absent this period
400    /// ([`LookupError::NotInPeriod`]).
401    ///
402    /// # Examples
403    ///
404    /// ```
405    /// use hmrc_rates::{YearMonth, Rates};
406    /// use rust_decimal::Decimal;
407    ///
408    /// let rates = Rates::new();
409    /// let table = rates.monthly(YearMonth::new(2025, 8).unwrap())?;
410    /// let eur = table.rate("EUR")?;
411    /// let total: Decimal = [1200, 450, 80]
412    ///     .into_iter()
413    ///     .map(|amount| eur.to_gbp(Decimal::from(amount)))
414    ///     .sum();
415    /// # Ok::<(), hmrc_rates::LookupError>(())
416    /// ```
417    pub fn rate(&self, code: &str) -> Result<Rate, LookupError> {
418        if let Some(rate) = gbp_identity(code, self.period) {
419            return Ok(rate);
420        }
421        let Some(normalized) = Currency::normalize(code) else {
422            return Err(LookupError::UnknownCurrency {
423                code: code.trim().into(),
424                table: self.rate_type,
425            });
426        };
427        match store::lookup(self.entries, normalized) {
428            Some(entry) => Ok(Rate::new(
429                entry.decimal(),
430                Currency::from_code(normalized),
431                self.period,
432            )),
433            None if self.known.knows(normalized) => Err(LookupError::NotInPeriod {
434                currency: Currency::from_code(normalized),
435                table: self.rate_type,
436                period: self.period,
437            }),
438            None => Err(LookupError::UnknownCurrency {
439                code: code.trim().into(),
440                table: self.rate_type,
441            }),
442        }
443    }
444
445    /// Like [`Table::rate`] but `None` on any miss, for when absence isn't exceptional.
446    pub fn get(&self, code: &str) -> Option<Rate> {
447        self.rate(code).ok()
448    }
449
450    /// All `(currency, rate)` pairs in this table, ascending by code.
451    pub fn iter(&self) -> impl ExactSizeIterator<Item = (Currency, Rate)> + use<'a> {
452        let period = self.period;
453        self.entries.iter().map(move |e| {
454            let currency = Currency::from_code(e.code);
455            (currency, Rate::new(e.decimal(), currency, period))
456        })
457    }
458
459    /// The number of currencies in this table.
460    pub fn len(&self) -> usize {
461        self.entries.len()
462    }
463
464    /// `true` if the table has no entries.
465    pub fn is_empty(&self) -> bool {
466        self.entries.is_empty()
467    }
468}
469
470#[cfg(test)]
471mod empty_tests {
472    use super::*;
473
474    #[test]
475    fn empty_rates_report_no_data_loaded() {
476        let rates = Rates::empty();
477        let year_month = YearMonth::new(2025, 8);
478        let Some(year_month) = year_month else { return };
479        let result = rates.monthly_rate("USD", year_month);
480        assert!(
481            matches!(
482                result,
483                Err(LookupError::PeriodNotAvailable {
484                    available: None,
485                    ..
486                })
487            ),
488            "unexpected: {result:?}"
489        );
490        assert!(rates.months().next().is_none());
491        assert_eq!(rates.currencies(RateType::Spot).count(), 0);
492        // GBP identity still holds with no data at all
493        assert!(rates.monthly_rate("GBP", year_month).is_ok());
494    }
495}
496
497#[cfg(all(test, feature = "bundled"))]
498#[allow(clippy::unwrap_used)]
499mod bundled_tests {
500    use super::*;
501
502    #[test]
503    fn statics_hold_codegen_invariants() {
504        for series in [
505            &crate::bundled::MONTHLY,
506            &crate::bundled::SPOT,
507            &crate::bundled::AVERAGE,
508        ] {
509            let mut start = 0usize;
510            for pair in series.index.windows(2) {
511                assert!(
512                    pair[0].key < pair[1].key,
513                    "index keys not strictly ascending"
514                );
515            }
516            for idx in series.index {
517                let table = &series.arena[start..idx.end as usize];
518                start = idx.end as usize;
519                assert!(!table.is_empty());
520                for entry in table {
521                    assert!(entry.mantissa > 0);
522                    assert!(entry.scale <= 9);
523                    assert!(entry.code.iter().all(u8::is_ascii_uppercase));
524                }
525                for pair in table.windows(2) {
526                    assert!(pair[0].code < pair[1].code, "codes not sorted/deduped");
527                }
528            }
529            assert_eq!(start, series.arena.len(), "index does not cover the arena");
530        }
531        for pair in crate::bundled::WEEKLY.index.windows(2) {
532            assert!(pair[0].end_day < pair[1].start_day, "overlapping weeks");
533        }
534    }
535
536    // Round-trip: the generated statics must match a fresh parse of the source file
537    #[cfg(feature = "std")]
538    #[test]
539    fn codegen_matches_fresh_parse() {
540        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/data/monthly/2025-08.xml");
541        let bytes = std::fs::read(path).unwrap();
542        let ((year, month), raw) = crate::parse::parse_monthly_xml(&bytes).unwrap();
543        let parsed = crate::parse::dedup_majority(raw).unwrap();
544
545        let rates = Rates::new();
546        let table = rates.monthly(YearMonth::new(year, month).unwrap()).unwrap();
547        assert_eq!(table.len(), parsed.len());
548        for rate in &parsed {
549            let entry = crate::store::lookup(table.entries, rate.code).unwrap();
550            assert_eq!((entry.mantissa, entry.scale), (rate.mantissa, rate.scale));
551        }
552
553        // Same for a year-end series: pins build.rs's YearEnd key encoding
554        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/data/average/2024-12.csv");
555        let bytes = std::fs::read(path).unwrap();
556        let parsed =
557            crate::parse::dedup_majority(crate::parse::parse_rates_csv(&bytes).unwrap()).unwrap();
558        let table = rates.average(YearEnd::december(2024)).unwrap();
559        assert_eq!(table.len(), parsed.len());
560        for rate in &parsed {
561            let entry = crate::store::lookup(table.entries, rate.code).unwrap();
562            assert_eq!((entry.mantissa, entry.scale), (rate.mantissa, rate.scale));
563        }
564    }
565}