1use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::ops::{Add, Sub};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12pub struct Money {
13 cents: u64,
15 currency: Currency,
17}
18
19#[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 #[must_use]
31 pub const fn from_cents(cents: u64, currency: Currency) -> Self {
32 Self { cents, currency }
33 }
34
35 #[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 #[must_use]
50 pub const fn usd(cents: u64) -> Self {
51 Self::from_cents(cents, Currency::USD)
52 }
53
54 #[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 #[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 #[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 #[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 #[must_use]
109 pub const fn zero(currency: Currency) -> Self {
110 Self { cents: 0, currency }
111 }
112
113 #[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); }
197
198 #[test]
199 fn money_times() {
200 let price = Money::usd(1); let total = price.times(1000); 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); let commission = order_value.percent_bps(500);
211 assert_eq!(commission.cents(), 50_000); assert_eq!(commission.to_string(), "$500.00");
213
214 let commission_8 = order_value.percent_bps(800);
216 assert_eq!(commission_8.cents(), 80_000); }
218
219 #[test]
220 fn platform_pricing_matches_architecture() {
221 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 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 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}