Skip to main content

ssh_cli/domain/
money.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Decimal money newtypes (G-DOM-08 / G-E2E-14) — **library compliance only**.
3//!
4//! Not exposed on any `ssh-cli` subcommand. Retained so domain-type rules stay
5//! testable without inventing fake money fields on SSH hosts.
6//!
7//! ssh-cli has no monetary domain. This module satisfies coordinated
8//! `rust_decimal` + newtype rules without injecting fake money into SSH config.
9//!
10//! Serialization uses **string only** (`serde-with-str`); never float.
11#![forbid(unsafe_code)]
12
13use super::error::DomainError;
14use rust_decimal::Decimal;
15use serde::{Deserialize, Serialize};
16use std::fmt;
17use std::marker::PhantomData;
18use std::str::FromStr;
19
20/// Currency marker (zero-sized). Scale is fixed per currency.
21pub trait Currency: Copy + Send + Sync + 'static {
22    /// ISO-like code.
23    fn code() -> &'static str;
24    /// Display symbol.
25    fn symbol() -> &'static str;
26    /// Decimal scale (fiat 2, JPY 0, BTC 8).
27    fn scale() -> u32;
28}
29
30/// Brazilian Real.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
32pub struct Brl;
33
34impl Currency for Brl {
35    fn code() -> &'static str {
36        "BRL"
37    }
38    fn symbol() -> &'static str {
39        "R$"
40    }
41    fn scale() -> u32 {
42        2
43    }
44}
45
46/// US Dollar.
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
48pub struct Usd;
49
50impl Currency for Usd {
51    fn code() -> &'static str {
52        "USD"
53    }
54    fn symbol() -> &'static str {
55        "$"
56    }
57    fn scale() -> u32 {
58        2
59    }
60}
61
62/// Amount of currency `C` with exact decimal arithmetic.
63///
64/// Cross-currency `Add` is a compile error (different `C`).
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(bound = "")]
67pub struct Money<C: Currency> {
68    #[serde(with = "rust_decimal::serde::str")]
69    amount: Decimal,
70    #[serde(skip)]
71    _currency: PhantomData<C>,
72}
73
74impl<C: Currency> Money<C> {
75    /// Builds money rounded to the currency scale.
76    pub fn try_new(amount: Decimal) -> Result<Self, DomainError> {
77        if amount.is_sign_negative() {
78            return Err(DomainError::new(
79                "money",
80                format!("{} amount must not be negative", C::code()),
81            ));
82        }
83        let amount = amount.round_dp(C::scale());
84        Ok(Self {
85            amount,
86            _currency: PhantomData,
87        })
88    }
89
90    /// Parses from a decimal string (no float).
91    pub fn try_from_str(s: impl AsRef<str>) -> Result<Self, DomainError> {
92        let d = Decimal::from_str(s.as_ref().trim())
93            .map_err(|e| DomainError::new("money", e.to_string()))?;
94        Self::try_new(d)
95    }
96
97    /// Zero amount.
98    #[must_use]
99    pub fn zero() -> Self {
100        Self {
101            amount: Decimal::ZERO,
102            _currency: PhantomData,
103        }
104    }
105
106    /// Underlying decimal.
107    #[must_use]
108    pub const fn amount(&self) -> Decimal {
109        self.amount
110    }
111
112    /// Currency code.
113    #[must_use]
114    pub fn code(&self) -> &'static str {
115        C::code()
116    }
117}
118
119impl<C: Currency> fmt::Display for Money<C> {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(f, "{} {}", self.amount, C::code())
122    }
123}
124
125impl<C: Currency> std::ops::Add for Money<C> {
126    type Output = Self;
127
128    fn add(self, rhs: Self) -> Self::Output {
129        Self {
130            amount: (self.amount + rhs.amount).round_dp(C::scale()),
131            _currency: PhantomData,
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use rust_decimal::dec;
140
141    #[test]
142    fn brl_add_and_serde_str() {
143        let a = Money::<Brl>::try_new(dec!(99.99)).unwrap();
144        let b = Money::<Brl>::try_new(dec!(0.01)).unwrap();
145        let t = a + b;
146        assert_eq!(t.amount(), dec!(100.00));
147        let j = serde_json::to_string(&t).unwrap();
148        // string form, not float
149        assert!(j.contains('\"') && j.contains("100"), "{j}");
150        let back: Money<Brl> = serde_json::from_str(&j).unwrap();
151        assert_eq!(back.amount(), dec!(100.00));
152    }
153
154    #[test]
155    fn rejects_negative() {
156        assert!(Money::<Usd>::try_new(dec!(-1)).is_err());
157    }
158
159    #[test]
160    fn from_str() {
161        let m = Money::<Usd>::try_from_str("12.34").unwrap();
162        assert_eq!(m.amount(), dec!(12.34));
163    }
164}