1use alloc::string::String;
7use core::fmt;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[must_use]
29pub struct Money {
30 amount: Decimal,
31 currency: CurrencyCode,
32}
33
34impl Money {
35 #[inline]
37 pub const fn new(amount: Decimal, currency: CurrencyCode) -> Self {
38 Self { amount, currency }
39 }
40
41 #[inline]
43 pub const fn zero(currency: CurrencyCode) -> Self {
44 Self { amount: Decimal::ZERO, currency }
45 }
46
47 #[inline]
49 #[must_use]
50 pub const fn amount(&self) -> Decimal {
51 self.amount
52 }
53
54 #[inline]
56 #[must_use]
57 pub const fn currency(&self) -> CurrencyCode {
58 self.currency
59 }
60
61 #[inline]
63 #[must_use]
64 pub const fn is_zero(&self) -> bool {
65 self.amount.is_zero()
66 }
67
68 #[inline]
70 #[must_use]
71 pub const fn is_positive(&self) -> bool {
72 self.amount.is_sign_positive() && !self.amount.is_zero()
73 }
74
75 #[inline]
77 #[must_use]
78 pub const fn is_negative(&self) -> bool {
79 self.amount.is_sign_negative() && !self.amount.is_zero()
80 }
81
82 #[inline]
87 #[must_use]
88 pub fn checked_add(self, other: Self) -> Option<Self> {
89 if self.currency != other.currency {
90 return None;
91 }
92 let amount = self.amount.checked_add(other.amount)?;
93 Some(Self { amount, currency: self.currency })
94 }
95
96 #[inline]
101 #[must_use]
102 pub fn checked_sub(self, other: Self) -> Option<Self> {
103 if self.currency != other.currency {
104 return None;
105 }
106 let amount = self.amount.checked_sub(other.amount)?;
107 Some(Self { amount, currency: self.currency })
108 }
109
110 #[inline]
112 #[must_use = "returns a new Money with rounded amount"]
113 pub fn round_dp(self, dp: u32) -> Self {
114 Self { amount: self.amount.round_dp(dp), currency: self.currency }
115 }
116
117 #[inline]
122 #[must_use = "returns a new Money with scaled amount"]
123 pub fn checked_mul_scalar(self, factor: Decimal) -> Option<Self> {
124 let amount = self.amount.checked_mul(factor)?;
125 Some(Self { amount, currency: self.currency })
126 }
127
128 #[must_use]
133 pub fn checked_div_scalar(self, divisor: Decimal) -> Option<Self> {
134 if divisor.is_zero() {
135 return None;
136 }
137 let amount = self.amount.checked_div(divisor)?;
138 Some(Self { amount, currency: self.currency })
139 }
140
141 #[must_use = "returns a new Money with absolute amount"]
143 pub fn abs(self) -> Self {
144 Self { amount: self.amount.abs(), currency: self.currency }
145 }
146
147 #[must_use = "returns a new Money with negated amount"]
149 pub fn negate(self) -> Self {
150 Self { amount: -self.amount, currency: self.currency }
151 }
152}
153
154impl fmt::Display for Money {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 write!(f, "{} {}", self.amount, self.currency)
157 }
158}
159
160#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
180pub struct CurrencyCode([u8; 3]);
181
182impl Default for CurrencyCode {
183 fn default() -> Self {
185 Self::USD
186 }
187}
188
189impl CurrencyCode {
190 pub const USD: Self = Self(*b"USD");
193 pub const EUR: Self = Self(*b"EUR");
195 pub const GBP: Self = Self(*b"GBP");
197 pub const JPY: Self = Self(*b"JPY");
199 pub const CAD: Self = Self(*b"CAD");
201 pub const AUD: Self = Self(*b"AUD");
203 pub const CHF: Self = Self(*b"CHF");
205 pub const CNY: Self = Self(*b"CNY");
207
208 #[must_use]
212 pub const fn from_bytes(bytes: [u8; 3]) -> Option<Self> {
213 if bytes[0].is_ascii_uppercase()
214 && bytes[1].is_ascii_uppercase()
215 && bytes[2].is_ascii_uppercase()
216 {
217 Some(Self(bytes))
218 } else {
219 None
220 }
221 }
222
223 #[inline]
225 #[must_use]
226 pub const fn as_str(&self) -> &str {
227 match core::str::from_utf8(&self.0) {
228 Ok(code) => code,
229 Err(_) => panic!("CurrencyCode always stores validated ASCII uppercase bytes"),
230 }
231 }
232}
233
234impl fmt::Debug for CurrencyCode {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 write!(f, "CurrencyCode({})", self.as_str())
237 }
238}
239
240impl fmt::Display for CurrencyCode {
241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242 f.write_str(self.as_str())
243 }
244}
245
246impl core::str::FromStr for CurrencyCode {
247 type Err = CurrencyCodeError;
248
249 fn from_str(s: &str) -> Result<Self, Self::Err> {
250 let bytes = s.as_bytes();
251 if bytes.len() != 3 {
252 return Err(CurrencyCodeError::InvalidLength(s.len()));
253 }
254 let arr = [bytes[0], bytes[1], bytes[2]];
255 let arr =
257 [arr[0].to_ascii_uppercase(), arr[1].to_ascii_uppercase(), arr[2].to_ascii_uppercase()];
258 Self::from_bytes(arr).ok_or(CurrencyCodeError::InvalidCharacters)
259 }
260}
261
262impl Serialize for CurrencyCode {
263 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
264 serializer.serialize_str(self.as_str())
265 }
266}
267
268impl<'de> Deserialize<'de> for CurrencyCode {
269 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
270 let s = String::deserialize(deserializer)?;
271 s.parse().map_err(serde::de::Error::custom)
272 }
273}
274
275#[derive(Debug, Clone)]
277#[non_exhaustive]
278pub enum CurrencyCodeError {
279 InvalidLength(usize),
281 InvalidCharacters,
283}
284
285impl fmt::Display for CurrencyCodeError {
286 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287 match self {
288 Self::InvalidLength(len) => {
289 write!(f, "currency code must be exactly 3 characters, got {len}")
290 }
291 Self::InvalidCharacters => f.write_str("currency code must contain only ASCII letters"),
292 }
293 }
294}
295
296#[cfg(feature = "std")]
297impl std::error::Error for CurrencyCodeError {}
298
299#[cfg(feature = "rusqlite")]
304impl rusqlite::types::FromSql for CurrencyCode {
305 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
306 let s = value.as_str()?;
307 s.parse::<Self>().map_err(|e| rusqlite::types::FromSqlError::Other(Box::new(e)))
308 }
309}
310
311#[cfg(feature = "rusqlite")]
312impl rusqlite::types::ToSql for CurrencyCode {
313 fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
314 Ok(rusqlite::types::ToSqlOutput::Borrowed(rusqlite::types::ValueRef::Text(
315 self.as_str().as_bytes(),
316 )))
317 }
318}
319
320#[cfg(feature = "sqlx-postgres")]
325impl sqlx::Type<sqlx::Postgres> for CurrencyCode {
326 fn type_info() -> sqlx::postgres::PgTypeInfo {
327 <&str as sqlx::Type<sqlx::Postgres>>::type_info()
328 }
329
330 fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
331 <&str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
332 }
333}
334
335#[cfg(feature = "sqlx-postgres")]
336impl<'q> sqlx::Encode<'q, sqlx::Postgres> for CurrencyCode {
337 fn encode_by_ref(
338 &self,
339 buf: &mut sqlx::postgres::PgArgumentBuffer,
340 ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
341 <&str as sqlx::Encode<'q, sqlx::Postgres>>::encode_by_ref(&self.as_str(), buf)
342 }
343}
344
345#[cfg(feature = "sqlx-postgres")]
346impl<'r> sqlx::Decode<'r, sqlx::Postgres> for CurrencyCode {
347 fn decode(
348 value: sqlx::postgres::PgValueRef<'r>,
349 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
350 let s = <&str as sqlx::Decode<'r, sqlx::Postgres>>::decode(value)?;
351 s.parse::<Self>().map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use proptest::prelude::*;
359 use rust_decimal_macros::dec;
360
361 fn arb_currency() -> impl Strategy<Value = CurrencyCode> {
362 prop_oneof![
363 Just(CurrencyCode::USD),
364 Just(CurrencyCode::EUR),
365 Just(CurrencyCode::GBP),
366 Just(CurrencyCode::JPY),
367 Just(CurrencyCode::CAD),
368 Just(CurrencyCode::AUD),
369 Just(CurrencyCode::CHF),
370 Just(CurrencyCode::CNY),
371 ]
372 }
373
374 #[test]
375 fn money_display() {
376 let m = Money::new(dec!(42.50), CurrencyCode::USD);
377 assert_eq!(m.to_string(), "42.50 USD");
378 }
379
380 #[test]
381 fn money_checked_add_same_currency() {
382 let a = Money::new(dec!(10.00), CurrencyCode::USD);
383 let b = Money::new(dec!(5.50), CurrencyCode::USD);
384 let sum = a.checked_add(b).unwrap();
385 assert_eq!(sum.amount(), dec!(15.50));
386 }
387
388 #[test]
389 fn money_checked_add_different_currency() {
390 let a = Money::new(dec!(10.00), CurrencyCode::USD);
391 let b = Money::new(dec!(5.50), CurrencyCode::EUR);
392 assert!(a.checked_add(b).is_none());
393 }
394
395 #[test]
396 fn currency_code_parse() {
397 let usd: CurrencyCode = "USD".parse().unwrap();
398 assert_eq!(usd, CurrencyCode::USD);
399
400 let lower: CurrencyCode = "eur".parse().unwrap();
401 assert_eq!(lower, CurrencyCode::EUR);
402 }
403
404 #[test]
405 fn currency_code_invalid() {
406 assert!("US".parse::<CurrencyCode>().is_err()); assert!("USDX".parse::<CurrencyCode>().is_err()); assert!("U$D".parse::<CurrencyCode>().is_err()); }
410
411 #[test]
412 fn currency_code_serde_roundtrip() {
413 let code = CurrencyCode::GBP;
414 let json = serde_json::to_string(&code).unwrap();
415 assert_eq!(json, "\"GBP\"");
416 let parsed: CurrencyCode = serde_json::from_str(&json).unwrap();
417 assert_eq!(parsed, code);
418 }
419
420 #[test]
421 fn money_zero() {
422 let z = Money::zero(CurrencyCode::JPY);
423 assert!(z.is_zero());
424 assert!(!z.is_positive());
425 assert!(!z.is_negative());
426 }
427
428 proptest! {
429 #[test]
430 fn checked_add_sub_are_inverses_for_same_currency(
431 a_raw in -1_000_000i64..1_000_000,
432 b_raw in -1_000_000i64..1_000_000,
433 currency in arb_currency(),
434 ) {
435 let a = Money::new(Decimal::new(a_raw, 2), currency);
436 let b = Money::new(Decimal::new(b_raw, 2), currency);
437 let sum = a.checked_add(b).unwrap();
438 let back = sum.checked_sub(b).unwrap();
439 prop_assert_eq!(back, a);
440 }
441 }
442
443 proptest! {
444 #[test]
445 fn checked_add_is_commutative_when_currency_matches(
446 a_raw in -1_000_000i64..1_000_000,
447 b_raw in -1_000_000i64..1_000_000,
448 currency in arb_currency(),
449 ) {
450 let a = Money::new(Decimal::new(a_raw, 3), currency);
451 let b = Money::new(Decimal::new(b_raw, 3), currency);
452 prop_assert_eq!(a.checked_add(b), b.checked_add(a));
453 }
454 }
455
456 proptest! {
457 #[test]
458 fn round_dp_is_idempotent(
459 raw in -100_000_000i64..100_000_000,
460 scale in 0u32..8,
461 dp in 0u32..8,
462 currency in arb_currency(),
463 ) {
464 let money = Money::new(Decimal::new(raw, scale), currency);
465 let once = money.round_dp(dp);
466 let twice = once.round_dp(dp);
467 prop_assert_eq!(once, twice);
468 prop_assert!(once.amount().scale() <= dp);
469 }
470 }
471
472 proptest! {
473 #[test]
474 fn checked_add_rejects_currency_mismatch(
475 a_raw in -1_000_000i64..1_000_000,
476 b_raw in -1_000_000i64..1_000_000,
477 ) {
478 let usd = Money::new(Decimal::new(a_raw, 2), CurrencyCode::USD);
479 let eur = Money::new(Decimal::new(b_raw, 2), CurrencyCode::EUR);
480 prop_assert!(usd.checked_add(eur).is_none());
481 prop_assert!(usd.checked_sub(eur).is_none());
482 }
483 }
484
485 #[test]
486 fn is_negative_returns_false_for_zero() {
487 let zero = Money::zero(CurrencyCode::USD);
488 assert!(!zero.is_negative());
489 assert!(!zero.is_positive());
490 }
491
492 #[test]
493 fn is_negative_returns_true_for_negative() {
494 let money = Money::new(dec!(-5.00), CurrencyCode::USD);
495 assert!(money.is_negative());
496 assert!(!money.is_positive());
497 }
498
499 #[test]
500 fn is_positive_returns_true_for_positive() {
501 let money = Money::new(dec!(5.00), CurrencyCode::USD);
502 assert!(money.is_positive());
503 assert!(!money.is_negative());
504 }
505
506 #[test]
507 fn checked_mul_scalar() {
508 let money = Money::new(dec!(10.00), CurrencyCode::USD);
509 let result = money.checked_mul_scalar(dec!(3)).unwrap();
510 assert_eq!(result.amount(), dec!(30.00));
511 assert_eq!(result.currency(), CurrencyCode::USD);
512 }
513
514 #[test]
515 fn checked_mul_scalar_fractional() {
516 let money = Money::new(dec!(100.00), CurrencyCode::USD);
517 let result = money.checked_mul_scalar(dec!(0.0825)).unwrap(); assert_eq!(result.amount(), dec!(8.2500));
519 }
520
521 #[test]
522 fn checked_div_scalar() {
523 let money = Money::new(dec!(30.00), CurrencyCode::USD);
524 let result = money.checked_div_scalar(dec!(3)).unwrap();
525 assert_eq!(result.amount(), dec!(10.00));
526 assert_eq!(result.currency(), CurrencyCode::USD);
527 }
528
529 #[test]
530 fn checked_div_scalar_zero_returns_none() {
531 let money = Money::new(dec!(30.00), CurrencyCode::USD);
532 assert!(money.checked_div_scalar(dec!(0)).is_none());
533 }
534
535 #[test]
536 fn abs_negative_becomes_positive() {
537 let money = Money::new(dec!(-5.00), CurrencyCode::USD);
538 let result = money.abs();
539 assert_eq!(result.amount(), dec!(5.00));
540 }
541
542 #[test]
543 fn abs_positive_stays_positive() {
544 let money = Money::new(dec!(5.00), CurrencyCode::USD);
545 let result = money.abs();
546 assert_eq!(result.amount(), dec!(5.00));
547 }
548
549 #[test]
550 fn negate_round_trip() {
551 let money = Money::new(dec!(5.00), CurrencyCode::USD);
552 let negated = money.negate();
553 assert_eq!(negated.amount(), dec!(-5.00));
554 let restored = negated.negate();
555 assert_eq!(restored.amount(), dec!(5.00));
556 }
557
558 #[test]
563 fn checked_add_returns_none_on_overflow() {
564 let max = Money::new(Decimal::MAX, CurrencyCode::USD);
565 let one = Money::new(Decimal::ONE, CurrencyCode::USD);
566 assert!(max.checked_add(one).is_none());
568 assert!(max.checked_add(max).is_none());
569 }
570
571 #[test]
572 fn checked_sub_returns_none_on_overflow() {
573 let min = Money::new(Decimal::MIN, CurrencyCode::USD);
574 let one = Money::new(Decimal::ONE, CurrencyCode::USD);
575 assert!(min.checked_sub(one).is_none());
577 let max = Money::new(Decimal::MAX, CurrencyCode::USD);
578 assert!(min.checked_sub(max).is_none());
580 }
581
582 #[test]
583 fn checked_mul_scalar_returns_none_on_overflow() {
584 let max = Money::new(Decimal::MAX, CurrencyCode::USD);
585 assert!(max.checked_mul_scalar(dec!(2)).is_none());
587 assert!(max.checked_mul_scalar(Decimal::MAX).is_none());
588 }
589
590 #[test]
591 fn checked_div_scalar_returns_none_on_overflow() {
592 let max = Money::new(Decimal::MAX, CurrencyCode::USD);
593 assert!(max.checked_div_scalar(dec!(0.0000000000000000000000000001)).is_none());
595 }
596
597 proptest! {
598 #[test]
601 fn checked_arithmetic_never_panics(
602 a_raw in any::<i64>(),
603 a_scale in 0u32..6,
604 b_raw in any::<i64>(),
605 b_scale in 0u32..6,
606 currency in arb_currency(),
607 ) {
608 let a = Money::new(Decimal::new(a_raw, a_scale), currency);
609 let b = Money::new(Decimal::new(b_raw, b_scale), currency);
610 let _ = a.checked_add(b);
612 let _ = a.checked_sub(b);
613 let _ = a.checked_mul_scalar(b.amount());
614 let _ = a.checked_div_scalar(b.amount());
615 }
616 }
617
618 proptest! {
619 #[test]
622 fn checked_arithmetic_at_extremes_never_panics(
623 factor_raw in any::<i64>(),
624 factor_scale in 0u32..28,
625 currency in arb_currency(),
626 ) {
627 let max = Money::new(Decimal::MAX, currency);
628 let min = Money::new(Decimal::MIN, currency);
629 let factor = Decimal::new(factor_raw, factor_scale);
630 let _ = max.checked_add(max);
631 let _ = max.checked_add(min);
632 let _ = min.checked_sub(max);
633 let _ = max.checked_mul_scalar(factor);
634 let _ = min.checked_mul_scalar(factor);
635 let _ = max.checked_div_scalar(factor);
636 let _ = min.checked_div_scalar(factor);
637 }
638 }
639}