Skip to main content

vr_core/
money.rs

1//! Money types — all financial amounts in cents to avoid floating point.
2//!
3//! All prices, commissions, invoices, and billing use integer cents (u64).
4//! This prevents floating-point rounding errors in financial calculations.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::ops::{Add, Sub};
9
10/// Money amount in the smallest currency unit (cents for USD).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12pub struct Money {
13    /// Amount in cents (1/100th of currency unit).
14    cents: u64,
15    /// ISO 4217 currency code.
16    currency: Currency,
17}
18
19/// Supported currencies.
20#[non_exhaustive]
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22pub enum Currency {
23    USD,
24    EUR,
25    GBP,
26}
27
28impl Money {
29    /// Create from cents.
30    #[must_use]
31    pub const fn from_cents(cents: u64, currency: Currency) -> Self {
32        Self { cents, currency }
33    }
34
35    /// Create from dollars (or major currency unit).
36    #[must_use]
37    #[allow(
38        clippy::arithmetic_side_effects,
39        reason = "dollars * 100 overflows only above 1.8e17 dollars, which is not a valid monetary input"
40    )]
41    pub const fn from_dollars(dollars: u64, currency: Currency) -> Self {
42        Self {
43            cents: dollars * 100,
44            currency,
45        }
46    }
47
48    /// USD convenience constructor.
49    #[must_use]
50    pub const fn usd(cents: u64) -> Self {
51        Self::from_cents(cents, Currency::USD)
52    }
53
54    /// USD from dollars.
55    #[must_use]
56    pub const fn usd_dollars(dollars: u64) -> Self {
57        Self::from_dollars(dollars, Currency::USD)
58    }
59
60    #[must_use]
61    pub const fn cents(&self) -> u64 {
62        self.cents
63    }
64
65    #[must_use]
66    pub const fn currency(&self) -> Currency {
67        self.currency
68    }
69
70    /// Convert to f64 dollars for display. NOT for calculations.
71    #[must_use]
72    #[allow(
73        clippy::as_conversions,
74        reason = "intentional lossy cast for display-only use; caller is warned not to use for calculations"
75    )]
76    pub fn as_dollars_f64(&self) -> f64 {
77        self.cents as f64 / 100.0
78    }
79
80    /// Multiply by a quantity (e.g., price * units).
81    #[must_use]
82    #[allow(
83        clippy::arithmetic_side_effects,
84        reason = "financial multiplication; callers are responsible for ensuring quantity does not cause overflow in their billing context"
85    )]
86    pub const fn times(self, quantity: u64) -> Self {
87        Self {
88            cents: self.cents * quantity,
89            currency: self.currency,
90        }
91    }
92
93    /// Apply a percentage (basis points: 100 = 1%, 10000 = 100%).
94    /// Commission calculation: amount.percent_bps(800) = 8%.
95    #[must_use]
96    #[allow(
97        clippy::arithmetic_side_effects,
98        reason = "bps is bounded 0..=10_000 by convention; intermediate product overflows only for amounts exceeding u64::MAX / 10_000"
99    )]
100    pub const fn percent_bps(self, bps: u64) -> Self {
101        Self {
102            cents: self.cents * bps / 10_000,
103            currency: self.currency,
104        }
105    }
106
107    /// Zero amount in same currency.
108    #[must_use]
109    pub const fn zero(currency: Currency) -> Self {
110        Self { cents: 0, currency }
111    }
112
113    /// Check if zero.
114    #[must_use]
115    pub const fn is_zero(&self) -> bool {
116        self.cents == 0
117    }
118}
119
120impl Add for Money {
121    type Output = Self;
122    fn add(self, rhs: Self) -> Self {
123        debug_assert_eq!(
124            self.currency, rhs.currency,
125            "cannot add different currencies"
126        );
127        Self {
128            cents: self.cents.saturating_add(rhs.cents),
129            currency: self.currency,
130        }
131    }
132}
133
134impl Sub for Money {
135    type Output = Self;
136    fn sub(self, rhs: Self) -> Self {
137        debug_assert_eq!(
138            self.currency, rhs.currency,
139            "cannot subtract different currencies"
140        );
141        Self {
142            cents: self.cents.saturating_sub(rhs.cents),
143            currency: self.currency,
144        }
145    }
146}
147
148impl fmt::Display for Money {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        let symbol = match self.currency {
151            Currency::USD => "$",
152            Currency::EUR => "€",
153            Currency::GBP => "£",
154        };
155        write!(f, "{}{}.{:02}", symbol, self.cents / 100, self.cents % 100)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn money_from_cents() {
165        let m = Money::usd(12345);
166        assert_eq!(m.cents(), 12345);
167        assert_eq!(m.as_dollars_f64(), 123.45);
168    }
169
170    #[test]
171    fn money_from_dollars() {
172        let m = Money::usd_dollars(250);
173        assert_eq!(m.cents(), 25000);
174    }
175
176    #[test]
177    fn money_display() {
178        assert_eq!(Money::usd(12345).to_string(), "$123.45");
179        assert_eq!(Money::usd(100).to_string(), "$1.00");
180        assert_eq!(Money::usd(5).to_string(), "$0.05");
181        assert_eq!(Money::from_cents(999, Currency::EUR).to_string(), "€9.99");
182    }
183
184    #[test]
185    fn money_add() {
186        let a = Money::usd(100);
187        let b = Money::usd(250);
188        assert_eq!((a + b).cents(), 350);
189    }
190
191    #[test]
192    fn money_sub_saturating() {
193        let a = Money::usd(100);
194        let b = Money::usd(250);
195        assert_eq!((a - b).cents(), 0); // saturating
196    }
197
198    #[test]
199    fn money_times() {
200        let price = Money::usd(1); // $0.01 per compound
201        let total = price.times(1000); // 1000 compounds
202        assert_eq!(total.cents(), 1000);
203        assert_eq!(total.to_string(), "$10.00");
204    }
205
206    #[test]
207    fn commission_percent_bps() {
208        let order_value = Money::usd_dollars(10_000); // $10,000 CRO order
209        // 5% commission = 500 basis points
210        let commission = order_value.percent_bps(500);
211        assert_eq!(commission.cents(), 50_000); // $500
212        assert_eq!(commission.to_string(), "$500.00");
213
214        // 8% commission = 800 basis points
215        let commission_8 = order_value.percent_bps(800);
216        assert_eq!(commission_8.cents(), 80_000); // $800
217    }
218
219    #[test]
220    fn platform_pricing_matches_architecture() {
221        // $0.01/compound scored
222        let compound_price = Money::usd(1);
223        let batch_100 = compound_price.times(100);
224        assert_eq!(batch_100.to_string(), "$1.00");
225
226        // $0.05/ML prediction
227        let ml_price = Money::usd(5);
228        let predictions_1000 = ml_price.times(1000);
229        assert_eq!(predictions_1000.to_string(), "$50.00");
230
231        // $0.10/GB/month storage overage
232        let storage_price = Money::usd(10);
233        let overage_50gb = storage_price.times(50);
234        assert_eq!(overage_50gb.to_string(), "$5.00");
235    }
236}