1use rust_decimal::prelude::ToPrimitive;
7use rust_decimal::Decimal;
8use schemars::{json_schema, JsonSchema, Schema, SchemaGenerator};
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10use std::borrow::Cow;
11use std::error::Error;
12use std::fmt;
13use std::fmt::{Debug, Display, Formatter};
14use std::str::FromStr;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct AmountFormat {
25 dollar: bool,
27 commas: bool,
29}
30
31impl Default for AmountFormat {
32 fn default() -> Self {
33 DEFAULT_FORMAT
34 }
35}
36
37const DEFAULT_FORMAT: AmountFormat = AmountFormat {
39 dollar: true,
40 commas: true,
41};
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
81pub struct Amount {
82 value: Decimal,
84 format: AmountFormat,
86}
87
88impl Amount {
89 pub const fn new(value: Decimal) -> Self {
91 Self {
92 value,
93 format: DEFAULT_FORMAT,
94 }
95 }
96
97 pub const fn new_with_format(value: Decimal, format: AmountFormat) -> Self {
99 Self { value, format }
100 }
101
102 pub fn value(&self) -> Decimal {
104 self.value
105 }
106
107 pub fn is_zero(&self) -> bool {
109 self.value().is_zero()
110 }
111
112 pub fn is_positive(&self) -> bool {
114 !self.is_zero() && self.value().is_sign_positive()
115 }
116
117 pub fn is_negative(&self) -> bool {
119 self.value().is_sign_negative()
120 }
121}
122
123pub struct AmountError(rust_decimal::Error);
125
126impl Debug for AmountError {
127 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
128 Debug::fmt(&self.0, f)
129 }
130}
131
132impl Display for AmountError {
133 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
134 Display::fmt(&self.0, f)
135 }
136}
137
138impl std::error::Error for AmountError {
139 fn source(&self) -> Option<&(dyn Error + 'static)> {
140 Some(&self.0)
141 }
142}
143
144impl FromStr for Amount {
145 type Err = AmountError;
146
147 fn from_str(s: &str) -> Result<Self, Self::Err> {
148 let mut dollar_sign = false;
149
150 let trimmed = s.trim();
152
153 if trimmed.is_empty() {
155 return Ok(Amount::default());
156 }
157
158 let without_dollar = if let Some(after_minus) = trimmed.strip_prefix('-') {
160 if let Some(after_dollar) = after_minus.strip_prefix('$') {
162 dollar_sign = true;
163 format!("-{after_dollar}")
164 } else {
165 trimmed.to_string()
166 }
167 } else if let Some(after_dollar) = trimmed.strip_prefix('$') {
168 dollar_sign = true;
170 after_dollar.to_string()
171 } else {
172 trimmed.to_string()
174 };
175
176 let without_commas = without_dollar.replace(',', "");
178 let commas = without_commas.len() < without_dollar.len();
179
180 let value = Decimal::from_str(&without_commas).map_err(AmountError)?;
182 Ok(Amount {
183 value,
184 format: AmountFormat {
185 dollar: dollar_sign,
186 commas,
187 },
188 })
189 }
190}
191
192impl fmt::Display for Amount {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 let (sign, num) = if self.is_negative() {
195 (String::from("-"), self.value().abs())
196 } else {
197 (String::new(), self.value())
198 };
199
200 let dol = if self.format.dollar {
201 String::from("$")
202 } else {
203 String::new()
204 };
205
206 if self.format.commas {
207 write!(
208 f,
209 "{sign}{dol}{}",
210 format_num::format_num!(",.2", num.to_f64().unwrap_or_default())
211 )
212 } else {
213 write!(f, "{sign}{dol}{num}")
214 }
215 }
216}
217
218impl Serialize for Amount {
219 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
220 where
221 S: Serializer,
222 {
223 serializer.serialize_str(&self.to_string())
225 }
226}
227
228impl<'de> Deserialize<'de> for Amount {
229 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
230 where
231 D: Deserializer<'de>,
232 {
233 let s = String::deserialize(deserializer)?;
234 Amount::from_str(&s).map_err(serde::de::Error::custom)
235 }
236}
237
238impl From<Decimal> for Amount {
239 fn from(value: Decimal) -> Self {
240 Amount::new(value)
241 }
242}
243
244impl From<Amount> for Decimal {
245 fn from(amount: Amount) -> Self {
246 amount.value()
247 }
248}
249
250impl JsonSchema for Amount {
251 fn schema_name() -> Cow<'static, str> {
252 "Amount".into()
253 }
254
255 fn json_schema(_: &mut SchemaGenerator) -> Schema {
256 json_schema!({
257 "type": "string",
258 "description": "A decimal number, with or without a dollar sign, negative sign or \
259 commas. Examples: 1.0 or -1 or -$1.21 or $3,452.12",
260 })
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn test_parse_with_dollar_sign() {
270 let amount = Amount::from_str("$50.00").unwrap();
271 assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
272 }
273
274 #[test]
275 fn test_parse_without_dollar_sign() {
276 let amount = Amount::from_str("50.00").unwrap();
277 assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
278 }
279
280 #[test]
281 fn test_parse_negative_with_dollar_sign() {
282 let amount = Amount::from_str("-$50.00").unwrap();
283 assert_eq!(amount.value(), Decimal::from_str("-50.00").unwrap());
284 }
285
286 #[test]
287 fn test_parse_negative_without_dollar_sign() {
288 let amount = Amount::from_str("-50.00").unwrap();
289 assert_eq!(amount.value(), Decimal::from_str("-50.00").unwrap());
290 }
291
292 #[test]
293 fn test_parse_empty_string() {
294 let amount = Amount::from_str("").unwrap();
295 assert_eq!(amount.value(), Decimal::ZERO);
296 }
297
298 #[test]
299 fn test_parse_whitespace() {
300 let amount = Amount::from_str(" $50.00 ").unwrap();
301 assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
302 }
303
304 #[test]
305 fn test_display_positive() {
306 let amount = Amount::new(Decimal::from_str("50.00").unwrap());
307 assert_eq!(amount.to_string(), "$50.00");
308 }
309
310 #[test]
311 fn test_display_negative() {
312 let amount = Amount::new(Decimal::from_str("-50.00").unwrap());
313 assert_eq!(amount.to_string(), "-$50.00");
314 }
315
316 #[test]
317 fn test_display_zero() {
318 let amount = Amount::new(Decimal::ZERO);
319 assert_eq!(amount.to_string(), "$0.00");
320 }
321
322 #[test]
323 fn test_serialize() {
324 let amount = Amount::new(Decimal::from_str("50.00").unwrap());
325 let json = serde_json::to_string(&amount).unwrap();
326 assert_eq!(json, "\"$50.00\"");
327 }
328
329 #[test]
330 fn test_deserialize_with_dollar() {
331 let json = "\"$50.00\"";
332 let amount: Amount = serde_json::from_str(json).unwrap();
333 assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
334 }
335
336 #[test]
337 fn test_deserialize_without_dollar() {
338 let json = "\"50.00\"";
339 let amount: Amount = serde_json::from_str(json).unwrap();
340 assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
341 }
342
343 #[test]
344 fn test_deserialize_negative() {
345 let json = "\"-$50.00\"";
346 let amount: Amount = serde_json::from_str(json).unwrap();
347 assert_eq!(amount.value(), Decimal::from_str("-50.00").unwrap());
348 }
349
350 #[test]
351 fn test_equality() {
352 let a1 = Amount::from_str("$50.00").unwrap();
353 let a2 = Amount::from_str("50.00").unwrap();
354 assert_ne!(a1, a2);
355 assert_eq!(a1.value(), a2.value());
356 }
357
358 #[test]
359 fn test_ordering() {
360 let a1 = Amount::from_str("$30.00").unwrap();
361 let a2 = Amount::from_str("$50.00").unwrap();
362 assert!(a1 < a2);
363 }
364
365 #[test]
366 fn test_is_zero() {
367 let zero = Amount::from_str("$0.00").unwrap();
368 assert!(zero.is_zero());
369
370 let non_zero = Amount::from_str("$50.00").unwrap();
371 assert!(!non_zero.is_zero());
372 }
373
374 #[test]
375 fn test_zero_is_not_positive_or_negative() {
376 let zero = Amount::from_str("$0.00").unwrap();
377 assert!(!zero.is_positive());
378 assert!(!zero.is_negative());
379 assert!(zero.is_zero());
380 }
381
382 #[test]
383 fn test_is_positive() {
384 let positive = Amount::from_str("$50.00").unwrap();
385 assert!(positive.is_positive());
386
387 let negative = Amount::from_str("-$50.00").unwrap();
388 assert!(!negative.is_positive());
389 }
390
391 #[test]
392 fn test_is_negative() {
393 let negative = Amount::from_str("-$50.00").unwrap();
394 assert!(negative.is_negative());
395
396 let positive = Amount::from_str("$50.00").unwrap();
397 assert!(!positive.is_negative());
398 }
399
400 #[test]
401 fn test_parse_with_commas() {
402 let amount = Amount::from_str("$1,000.00").unwrap();
403 assert_eq!(amount.value(), Decimal::from_str("1000.00").unwrap());
404 }
405
406 #[test]
407 fn test_parse_large_amount_with_commas() {
408 let amount = Amount::from_str("-$60,000.00").unwrap();
409 assert_eq!(amount.value(), Decimal::from_str("-60000.00").unwrap());
410 }
411
412 #[test]
413 fn test_parse_multiple_commas() {
414 let amount = Amount::from_str("$1,234,567.89").unwrap();
415 assert_eq!(amount.value(), Decimal::from_str("1234567.89").unwrap());
416 }
417
418 #[test]
419 fn test_parse_commas_without_dollar() {
420 let amount = Amount::from_str("1,000.00").unwrap();
421 assert_eq!(amount.value(), Decimal::from_str("1000.00").unwrap());
422 }
423
424 #[test]
425 fn test_parse_retain_commas_no_dollarsign() {
426 let s = "1,000,000.00";
427 let amount = Amount::from_str(s).unwrap();
428 let actual = amount.to_string();
429 assert_eq!(actual, s);
430 }
431
432 #[test]
433 fn test_parse_no_commas_retain_dollar_sign() {
434 let s = "-$1000000.00";
435 let amount = Amount::from_str(s).unwrap();
436 let actual = amount.to_string();
437 assert_eq!(actual, s);
438 }
439}