sofie_values/
percentage.rs1use 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#[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 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.message()))
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}