Skip to main content

sofie_values/
percentage.rs

1use std::convert::Infallible;
2use std::fmt;
3
4use serde::de::{self, Unexpected, Visitor};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use sofie_core::value::{self, Value};
7use sofie_core::{Ordered, Result};
8
9#[cfg(feature = "web")]
10use crate::Conventions;
11use crate::{Control, Input, Money, Present};
12
13const BASIS_POINTS_PER_WHOLE: i64 = 10_000;
14
15/// Basis points, so it is exact; may be negative or above 100 %. A JSON number is basis points,
16/// text is a percent as a person types it: `"19.5"` is 1950.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct Percentage {
19    basis_points: i64,
20}
21
22impl Percentage {
23    #[must_use]
24    pub fn from_basis_points(basis_points: i64) -> Self {
25        Self { basis_points }
26    }
27
28    #[must_use]
29    pub fn basis_points(self) -> i64 {
30        self.basis_points
31    }
32
33    /// Rounded half away from zero to the minor unit.
34    pub fn of(self, amount: Money) -> Result<Money> {
35        amount.scale(self.basis_points, BASIS_POINTS_PER_WHOLE)
36    }
37}
38
39impl Value for Percentage {
40    type Inner = i64;
41
42    fn inner(&self) -> &i64 {
43        &self.basis_points
44    }
45
46    fn try_from_inner(basis_points: i64) -> Result<Self, impl std::fmt::Display> {
47        Ok::<_, Infallible>(Self::from_basis_points(basis_points))
48    }
49}
50
51impl Ordered for Percentage {}
52
53impl Serialize for Percentage {
54    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
55        value::serialize(self, serializer)
56    }
57}
58
59impl<'de> Deserialize<'de> for Percentage {
60    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
61        deserializer.deserialize_any(PercentageVisitor)
62    }
63}
64
65struct PercentageVisitor;
66
67impl Visitor<'_> for PercentageVisitor {
68    type Value = Percentage;
69
70    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        formatter.write_str("whole basis points, or a percent with up to two decimals as text")
72    }
73
74    fn visit_i64<E: de::Error>(self, basis_points: i64) -> Result<Percentage, E> {
75        Ok(Percentage::from_basis_points(basis_points))
76    }
77
78    fn visit_u64<E: de::Error>(self, basis_points: u64) -> Result<Percentage, E> {
79        i64::try_from(basis_points)
80            .map(Percentage::from_basis_points)
81            .map_err(|_| E::invalid_value(Unexpected::Unsigned(basis_points), &self))
82    }
83
84    fn visit_str<E: de::Error>(self, percent: &str) -> Result<Percentage, E> {
85        crate::decimal::parse_scaled(percent, 2)
86            .map(Percentage::from_basis_points)
87            .map_err(|refusal| E::custom(refusal.reason().1))
88    }
89}
90
91impl Present for Percentage {
92    const INPUT: Input = Input::new(Control::Percentage);
93    const SEARCH: crate::Search = crate::Search::Number;
94
95    fn csv(&self) -> String {
96        crate::percent_text(self.basis_points)
97    }
98
99    #[cfg(feature = "web")]
100    fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
101        conventions.write_number(out, &self.csv());
102        out.push_str(" %");
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use sofie_core::SqlValue;
109
110    use super::*;
111    use crate::testing::{euros, round_trip};
112
113    #[test]
114    fn takes_its_share_of_an_amount_rounded_half_away_from_zero() {
115        let rate = Percentage::from_basis_points(1900);
116        let share = Percentage::from_basis_points;
117
118        assert_eq!(rate.of(euros(1005)).unwrap(), euros(191));
119        assert_eq!(rate.of(euros(-1005)).unwrap(), euros(-191));
120        assert_eq!(share(100).of(euros(50)).unwrap(), euros(1));
121        assert_eq!(share(100).of(euros(49)).unwrap(), euros(0));
122        assert_eq!(share(15_000).of(euros(1000)).unwrap(), euros(1500));
123    }
124
125    #[test]
126    fn is_stored_and_serialized_as_basis_points() {
127        let rate = Percentage::from_basis_points(1250);
128        assert_eq!(round_trip(&rate), vec![SqlValue::Int(1250)]);
129        assert_eq!(serde_json::to_value(rate).unwrap(), serde_json::json!(1250));
130        assert_eq!(
131            serde_json::from_value::<Percentage>(serde_json::json!(-50)).unwrap(),
132            Percentage::from_basis_points(-50)
133        );
134    }
135
136    #[test]
137    fn refuses_a_fraction_of_a_basis_point() {
138        assert!(serde_json::from_value::<Percentage>(serde_json::json!(12.5)).is_err());
139        assert!(serde_json::from_value::<Percentage>(serde_json::json!("12.555")).is_err());
140    }
141
142    #[test]
143    fn reads_text_as_a_typed_percent() {
144        let typed = |text: &str| serde_json::from_value::<Percentage>(serde_json::json!(text));
145
146        assert_eq!(typed("19").unwrap(), Percentage::from_basis_points(1900));
147        assert_eq!(typed("-0.5").unwrap(), Percentage::from_basis_points(-50));
148        assert!(typed("19%").is_err());
149        assert_eq!(Percentage::INPUT.control, Control::Percentage);
150    }
151
152    #[test]
153    fn orders_by_basis_points() {
154        assert!(Percentage::from_basis_points(-1) < Percentage::from_basis_points(0));
155        assert_eq!(Percentage::from_basis_points(7).basis_points(), 7);
156    }
157}