Skip to main content

allsource_core/domain/value_objects/
money.rs

1use crate::error::Result;
2use serde::{Deserialize, Serialize};
3use std::{
4    fmt,
5    ops::{Add, Sub},
6};
7
8/// Supported currencies in the paywall system
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub enum Currency {
11    /// US Dollar (for display purposes)
12    USD,
13    /// USDC stablecoin on Solana
14    USDC,
15    /// Native SOL token
16    SOL,
17}
18
19impl Currency {
20    /// Get the number of decimal places for this currency
21    pub fn decimals(&self) -> u8 {
22        match self {
23            Currency::USD => 2,
24            Currency::USDC => 6, // USDC has 6 decimals on Solana
25            Currency::SOL => 9,  // SOL has 9 decimals
26        }
27    }
28
29    /// Get the currency symbol
30    pub fn symbol(&self) -> &'static str {
31        match self {
32            Currency::USD => "$",
33            Currency::USDC => "USDC",
34            Currency::SOL => "SOL",
35        }
36    }
37}
38
39impl fmt::Display for Currency {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        write!(f, "{}", self.symbol())
42    }
43}
44
45/// Value Object: Money
46///
47/// Represents a monetary amount with currency in the paywall system.
48/// Amounts are stored as the smallest unit (e.g., cents for USD, lamports for SOL).
49///
50/// Domain Rules:
51/// - Amount must be non-negative
52/// - Currency must be specified
53/// - Immutable once created
54/// - Operations preserve currency (cannot add USD to SOL)
55///
56/// This is a Value Object:
57/// - Defined by its value, not identity
58/// - Immutable
59/// - Self-validating
60/// - Compared by value equality
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub struct Money {
63    /// Amount in smallest unit (cents, lamports, etc.)
64    amount: u64,
65    /// The currency
66    currency: Currency,
67}
68
69impl Money {
70    /// Create a new Money value from the smallest unit
71    ///
72    /// # Examples
73    /// ```
74    /// use allsource_core::domain::value_objects::{Money, Currency};
75    ///
76    /// // 50 cents
77    /// let money = Money::new(50, Currency::USD);
78    /// assert_eq!(money.amount(), 50);
79    /// assert_eq!(money.currency(), Currency::USD);
80    /// ```
81    pub fn new(amount: u64, currency: Currency) -> Self {
82        Self { amount, currency }
83    }
84
85    /// Create a Money value from a decimal amount
86    ///
87    /// # Examples
88    /// ```
89    /// use allsource_core::domain::value_objects::{Money, Currency};
90    ///
91    /// // $0.50
92    /// let money = Money::from_decimal(0.50, Currency::USD);
93    /// assert_eq!(money.amount(), 50);
94    /// ```
95    pub fn from_decimal(decimal: f64, currency: Currency) -> Self {
96        let multiplier = 10_u64.pow(u32::from(currency.decimals()));
97        let amount = (decimal * multiplier as f64).round() as u64;
98        Self { amount, currency }
99    }
100
101    /// Create zero Money
102    pub fn zero(currency: Currency) -> Self {
103        Self {
104            amount: 0,
105            currency,
106        }
107    }
108
109    /// Create USD Money from cents
110    pub fn usd_cents(cents: u64) -> Self {
111        Self::new(cents, Currency::USD)
112    }
113
114    /// Create USD Money from dollars (decimal)
115    pub fn usd(dollars: f64) -> Self {
116        Self::from_decimal(dollars, Currency::USD)
117    }
118
119    /// Create USDC Money from smallest unit
120    pub fn usdc(amount: u64) -> Self {
121        Self::new(amount, Currency::USDC)
122    }
123
124    /// Create USDC Money from decimal
125    pub fn usdc_decimal(amount: f64) -> Self {
126        Self::from_decimal(amount, Currency::USDC)
127    }
128
129    /// Create SOL Money from lamports
130    pub fn lamports(lamports: u64) -> Self {
131        Self::new(lamports, Currency::SOL)
132    }
133
134    /// Create SOL Money from decimal
135    pub fn sol(amount: f64) -> Self {
136        Self::from_decimal(amount, Currency::SOL)
137    }
138
139    /// Get the amount in smallest unit
140    pub fn amount(&self) -> u64 {
141        self.amount
142    }
143
144    /// Get the currency
145    pub fn currency(&self) -> Currency {
146        self.currency
147    }
148
149    /// Get the amount as a decimal
150    pub fn as_decimal(&self) -> f64 {
151        let divisor = 10_u64.pow(u32::from(self.currency.decimals()));
152        self.amount as f64 / divisor as f64
153    }
154
155    /// Check if this amount is zero
156    pub fn is_zero(&self) -> bool {
157        self.amount == 0
158    }
159
160    /// Check if this amount is positive (non-zero)
161    pub fn is_positive(&self) -> bool {
162        self.amount > 0
163    }
164
165    /// Check if this amount is at least a minimum value
166    pub fn at_least(&self, minimum: &Money) -> Result<()> {
167        if self.currency != minimum.currency {
168            return Err(crate::error::AllSourceError::InvalidInput(format!(
169                "Cannot compare {} with {}",
170                self.currency, minimum.currency
171            )));
172        }
173
174        if self.amount < minimum.amount {
175            return Err(crate::error::AllSourceError::ValidationError(format!(
176                "Amount {} is less than minimum {}",
177                self.as_decimal(),
178                minimum.as_decimal()
179            )));
180        }
181
182        Ok(())
183    }
184
185    /// Calculate a percentage of this amount
186    ///
187    /// # Examples
188    /// ```
189    /// use allsource_core::domain::value_objects::{Money, Currency};
190    ///
191    /// let money = Money::usd_cents(1000); // $10.00
192    /// let fee = money.percentage(7); // 7% = 70 cents
193    /// assert_eq!(fee.amount(), 70);
194    /// ```
195    pub fn percentage(&self, percent: u64) -> Self {
196        let amount = (self.amount * percent) / 100;
197        Self {
198            amount,
199            currency: self.currency,
200        }
201    }
202
203    /// Subtract a percentage and return the remainder
204    ///
205    /// # Examples
206    /// ```
207    /// use allsource_core::domain::value_objects::{Money, Currency};
208    ///
209    /// let money = Money::usd_cents(1000); // $10.00
210    /// let after_fee = money.subtract_percentage(7); // 7% fee = $9.30
211    /// assert_eq!(after_fee.amount(), 930);
212    /// ```
213    pub fn subtract_percentage(&self, percent: u64) -> Self {
214        let fee = (self.amount * percent) / 100;
215        Self {
216            amount: self.amount.saturating_sub(fee),
217            currency: self.currency,
218        }
219    }
220}
221
222impl fmt::Display for Money {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        match self.currency {
225            Currency::USD => write!(f, "${:.2}", self.as_decimal()),
226            Currency::USDC => write!(f, "{:.2} USDC", self.as_decimal()),
227            Currency::SOL => write!(f, "{:.4} SOL", self.as_decimal()),
228        }
229    }
230}
231
232impl Add for Money {
233    type Output = Result<Money>;
234
235    fn add(self, other: Money) -> Self::Output {
236        if self.currency != other.currency {
237            return Err(crate::error::AllSourceError::InvalidInput(format!(
238                "Cannot add {} to {}",
239                self.currency, other.currency
240            )));
241        }
242
243        Ok(Money {
244            amount: self.amount + other.amount,
245            currency: self.currency,
246        })
247    }
248}
249
250impl Sub for Money {
251    type Output = Result<Money>;
252
253    fn sub(self, other: Money) -> Self::Output {
254        if self.currency != other.currency {
255            return Err(crate::error::AllSourceError::InvalidInput(format!(
256                "Cannot subtract {} from {}",
257                other.currency, self.currency
258            )));
259        }
260
261        Ok(Money {
262            amount: self.amount.saturating_sub(other.amount),
263            currency: self.currency,
264        })
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_create_money_from_smallest_unit() {
274        let money = Money::new(50, Currency::USD);
275        assert_eq!(money.amount(), 50);
276        assert_eq!(money.currency(), Currency::USD);
277    }
278
279    #[test]
280    fn test_create_money_from_decimal() {
281        let money = Money::from_decimal(0.50, Currency::USD);
282        assert_eq!(money.amount(), 50);
283        assert_eq!(money.currency(), Currency::USD);
284
285        let money = Money::from_decimal(1.00, Currency::USDC);
286        assert_eq!(money.amount(), 1_000_000); // 6 decimals
287    }
288
289    #[test]
290    fn test_usd_helpers() {
291        let cents = Money::usd_cents(150);
292        assert_eq!(cents.amount(), 150);
293        assert_eq!(cents.as_decimal(), 1.50);
294
295        let dollars = Money::usd(1.50);
296        assert_eq!(dollars.amount(), 150);
297    }
298
299    #[test]
300    fn test_usdc_helpers() {
301        let usdc = Money::usdc_decimal(1.0);
302        assert_eq!(usdc.amount(), 1_000_000);
303        assert_eq!(usdc.currency(), Currency::USDC);
304    }
305
306    #[test]
307    fn test_sol_helpers() {
308        let sol = Money::sol(1.0);
309        assert_eq!(sol.amount(), 1_000_000_000);
310        assert_eq!(sol.currency(), Currency::SOL);
311
312        let lamports = Money::lamports(1_000_000_000);
313        assert_eq!(lamports.as_decimal(), 1.0);
314    }
315
316    #[test]
317    fn test_zero() {
318        let zero = Money::zero(Currency::USD);
319        assert!(zero.is_zero());
320        assert!(!zero.is_positive());
321    }
322
323    #[test]
324    fn test_is_positive() {
325        let money = Money::usd_cents(100);
326        assert!(money.is_positive());
327        assert!(!money.is_zero());
328    }
329
330    #[test]
331    fn test_at_least() {
332        let amount = Money::usd_cents(100);
333        let minimum = Money::usd_cents(50);
334
335        assert!(amount.at_least(&minimum).is_ok());
336
337        let small = Money::usd_cents(25);
338        assert!(small.at_least(&minimum).is_err());
339    }
340
341    #[test]
342    fn test_at_least_different_currency_fails() {
343        let usd = Money::usd_cents(100);
344        let sol = Money::lamports(100);
345
346        assert!(usd.at_least(&sol).is_err());
347    }
348
349    #[test]
350    fn test_percentage() {
351        let money = Money::usd_cents(1000); // $10.00
352        let fee = money.percentage(7); // 7%
353        assert_eq!(fee.amount(), 70); // 70 cents
354    }
355
356    #[test]
357    fn test_subtract_percentage() {
358        let money = Money::usd_cents(1000); // $10.00
359        let after_fee = money.subtract_percentage(7); // 7% fee
360        assert_eq!(after_fee.amount(), 930); // $9.30
361    }
362
363    #[test]
364    fn test_add_same_currency() {
365        let a = Money::usd_cents(100);
366        let b = Money::usd_cents(50);
367        let result = (a + b).unwrap();
368        assert_eq!(result.amount(), 150);
369    }
370
371    #[test]
372    fn test_add_different_currency_fails() {
373        let usd = Money::usd_cents(100);
374        let sol = Money::lamports(100);
375        let result = usd + sol;
376        assert!(result.is_err());
377    }
378
379    #[test]
380    fn test_sub_same_currency() {
381        let a = Money::usd_cents(100);
382        let b = Money::usd_cents(50);
383        let result = (a - b).unwrap();
384        assert_eq!(result.amount(), 50);
385    }
386
387    #[test]
388    fn test_sub_saturating() {
389        let a = Money::usd_cents(50);
390        let b = Money::usd_cents(100);
391        let result = (a - b).unwrap();
392        assert_eq!(result.amount(), 0); // Saturates at 0
393    }
394
395    #[test]
396    fn test_sub_different_currency_fails() {
397        let usd = Money::usd_cents(100);
398        let sol = Money::lamports(100);
399        let result = usd - sol;
400        assert!(result.is_err());
401    }
402
403    #[test]
404    fn test_display_usd() {
405        let money = Money::usd_cents(150);
406        assert_eq!(format!("{money}"), "$1.50");
407    }
408
409    #[test]
410    fn test_display_usdc() {
411        let money = Money::usdc_decimal(1.50);
412        assert_eq!(format!("{money}"), "1.50 USDC");
413    }
414
415    #[test]
416    fn test_display_sol() {
417        let money = Money::sol(0.001);
418        assert_eq!(format!("{money}"), "0.0010 SOL");
419    }
420
421    #[test]
422    fn test_currency_decimals() {
423        assert_eq!(Currency::USD.decimals(), 2);
424        assert_eq!(Currency::USDC.decimals(), 6);
425        assert_eq!(Currency::SOL.decimals(), 9);
426    }
427
428    #[test]
429    fn test_currency_symbol() {
430        assert_eq!(Currency::USD.symbol(), "$");
431        assert_eq!(Currency::USDC.symbol(), "USDC");
432        assert_eq!(Currency::SOL.symbol(), "SOL");
433    }
434
435    #[test]
436    fn test_equality() {
437        let a = Money::usd_cents(100);
438        let b = Money::usd_cents(100);
439        let c = Money::usd_cents(200);
440        let d = Money::new(100, Currency::SOL);
441
442        assert_eq!(a, b);
443        assert_ne!(a, c);
444        assert_ne!(a, d); // Different currency
445    }
446
447    #[test]
448    fn test_cloning() {
449        let a = Money::usd_cents(100);
450        let b = a; // Copy
451        assert_eq!(a, b);
452    }
453
454    #[test]
455    fn test_hash_consistency() {
456        use std::collections::HashSet;
457
458        let a = Money::usd_cents(100);
459        let b = Money::usd_cents(100);
460
461        let mut set = HashSet::new();
462        set.insert(a);
463
464        assert!(set.contains(&b));
465    }
466
467    #[test]
468    fn test_serde_serialization() {
469        let money = Money::usd_cents(150);
470
471        // Serialize
472        let json = serde_json::to_string(&money).unwrap();
473
474        // Deserialize
475        let deserialized: Money = serde_json::from_str(&json).unwrap();
476        assert_eq!(deserialized, money);
477    }
478
479    #[test]
480    fn test_as_decimal_precision() {
481        // Test USD (2 decimals)
482        let usd = Money::usd_cents(123);
483        assert!((usd.as_decimal() - 1.23).abs() < f64::EPSILON);
484
485        // Test USDC (6 decimals)
486        let usdc = Money::usdc(1_234_567);
487        assert!((usdc.as_decimal() - 1.234567).abs() < 0.000001);
488
489        // Test SOL (9 decimals)
490        let sol = Money::lamports(1_234_567_890);
491        assert!((sol.as_decimal() - 1.23456789).abs() < 0.000000001);
492    }
493}