Skip to main content

allsource_core/domain/value_objects/
money.rs

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