1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::error::{Error, Result};
10
11#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Budget {
15 pub amount: u64,
17 pub unit: String,
19 pub decimals: u32,
21}
22
23const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
25
26impl Budget {
27 pub fn new(amount: u64, unit: impl Into<String>, decimals: u32) -> Self {
29 Self {
30 amount,
31 unit: unit.into(),
32 decimals,
33 }
34 }
35
36 pub fn from_value(value: Option<&Value>) -> Result<Option<Self>> {
49 let Some(value) = value.filter(|v| !v.is_null()) else {
50 return Ok(None);
51 };
52 let invalid = || Error::Invalid("budget is {amount, unit, decimals}".into());
53 let amount = value
54 .get("amount")
55 .and_then(Value::as_u64)
56 .filter(|a| *a <= MAX_SAFE_INTEGER)
57 .ok_or_else(invalid)?;
58 let unit = value
59 .get("unit")
60 .and_then(Value::as_str)
61 .ok_or_else(invalid)?;
62 let decimals = value
63 .get("decimals")
64 .and_then(Value::as_u64)
65 .and_then(|d| u32::try_from(d).ok())
66 .ok_or_else(invalid)?;
67 Ok(Some(Self::new(amount, unit, decimals)))
68 }
69
70 pub fn same_scale(&self, other: &Budget) -> bool {
73 self.unit == other.unit && self.decimals == other.decimals
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80 use serde_json::json;
81
82 #[test]
83 fn budgets_parse_strictly() {
84 for bad in [
85 json!({"amount": -1, "unit": "USD", "decimals": 2}),
86 json!({"amount": 1.5, "unit": "USD", "decimals": 2}),
87 json!({"amount": 1, "decimals": 2}),
88 json!({"amount": 1, "unit": "USD", "decimals": -2}),
89 json!({"amount": 9_007_199_254_740_992u64, "unit": "USD", "decimals": 2}),
90 json!("100 USD"),
91 ] {
92 assert!(Budget::from_value(Some(&bad)).is_err(), "{bad}");
93 }
94 assert!(Budget::from_value(Some(&Value::Null)).unwrap().is_none());
95 }
96
97 #[test]
98 fn only_the_same_unit_and_scale_compare() {
99 let usd = Budget::new(1, "USD", 2);
100 assert!(usd.same_scale(&Budget::new(5, "USD", 2)));
101 assert!(!usd.same_scale(&Budget::new(5, "USD", 0)));
102 assert!(!usd.same_scale(&Budget::new(5, "EUR", 2)));
103 }
104}