Skip to main content

stateset_core/models/
currency.rs

1//! Multi-currency support types
2//!
3//! Provides ISO 4217 currency codes, money representation, and exchange rates.
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::str::FromStr;
10use uuid::Uuid;
11
12/// ISO 4217 Currency codes
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "UPPERCASE")]
15#[non_exhaustive]
16pub enum Currency {
17    /// US Dollar
18    #[default]
19    USD,
20    /// Euro
21    EUR,
22    /// British Pound Sterling
23    GBP,
24    /// Japanese Yen
25    JPY,
26    /// Canadian Dollar
27    CAD,
28    /// Australian Dollar
29    AUD,
30    /// Swiss Franc
31    CHF,
32    /// Chinese Yuan
33    CNY,
34    /// Hong Kong Dollar
35    HKD,
36    /// Singapore Dollar
37    SGD,
38    /// Swedish Krona
39    SEK,
40    /// Norwegian Krone
41    NOK,
42    /// Danish Krone
43    DKK,
44    /// New Zealand Dollar
45    NZD,
46    /// Mexican Peso
47    MXN,
48    /// Indian Rupee
49    INR,
50    /// Brazilian Real
51    BRL,
52    /// South Korean Won
53    KRW,
54    /// South African Rand
55    ZAR,
56    /// Russian Ruble
57    RUB,
58    /// Turkish Lira
59    TRY,
60    /// Polish Zloty
61    PLN,
62    /// Thai Baht
63    THB,
64    /// Indonesian Rupiah
65    IDR,
66    /// Malaysian Ringgit
67    MYR,
68    /// Philippine Peso
69    PHP,
70    /// Czech Koruna
71    CZK,
72    /// Israeli New Shekel
73    ILS,
74    /// United Arab Emirates Dirham
75    AED,
76    /// Saudi Riyal
77    SAR,
78    /// Taiwan Dollar
79    TWD,
80    /// Vietnamese Dong
81    VND,
82    /// Bitcoin (crypto)
83    BTC,
84    /// Ethereum (crypto)
85    ETH,
86    /// USD Coin (stablecoin)
87    USDC,
88    /// Tether (stablecoin)
89    USDT,
90}
91
92impl Currency {
93    /// Get the currency code as a string
94    #[must_use]
95    pub const fn code(&self) -> &'static str {
96        match self {
97            Self::USD => "USD",
98            Self::EUR => "EUR",
99            Self::GBP => "GBP",
100            Self::JPY => "JPY",
101            Self::CAD => "CAD",
102            Self::AUD => "AUD",
103            Self::CHF => "CHF",
104            Self::CNY => "CNY",
105            Self::HKD => "HKD",
106            Self::SGD => "SGD",
107            Self::SEK => "SEK",
108            Self::NOK => "NOK",
109            Self::DKK => "DKK",
110            Self::NZD => "NZD",
111            Self::MXN => "MXN",
112            Self::INR => "INR",
113            Self::BRL => "BRL",
114            Self::KRW => "KRW",
115            Self::ZAR => "ZAR",
116            Self::RUB => "RUB",
117            Self::TRY => "TRY",
118            Self::PLN => "PLN",
119            Self::THB => "THB",
120            Self::IDR => "IDR",
121            Self::MYR => "MYR",
122            Self::PHP => "PHP",
123            Self::CZK => "CZK",
124            Self::ILS => "ILS",
125            Self::AED => "AED",
126            Self::SAR => "SAR",
127            Self::TWD => "TWD",
128            Self::VND => "VND",
129            Self::BTC => "BTC",
130            Self::ETH => "ETH",
131            Self::USDC => "USDC",
132            Self::USDT => "USDT",
133        }
134    }
135
136    /// Get the currency symbol
137    #[must_use]
138    pub const fn symbol(&self) -> &'static str {
139        match self {
140            Self::USD => "$",
141            Self::EUR => "€",
142            Self::GBP => "£",
143            Self::JPY => "¥",
144            Self::CAD => "C$",
145            Self::AUD => "A$",
146            Self::CHF => "CHF",
147            Self::CNY => "¥",
148            Self::HKD => "HK$",
149            Self::SGD => "S$",
150            Self::SEK => "kr",
151            Self::NOK => "kr",
152            Self::DKK => "kr",
153            Self::NZD => "NZ$",
154            Self::MXN => "$",
155            Self::INR => "₹",
156            Self::BRL => "R$",
157            Self::KRW => "₩",
158            Self::ZAR => "R",
159            Self::RUB => "₽",
160            Self::TRY => "₺",
161            Self::PLN => "zł",
162            Self::THB => "฿",
163            Self::IDR => "Rp",
164            Self::MYR => "RM",
165            Self::PHP => "₱",
166            Self::CZK => "Kč",
167            Self::ILS => "₪",
168            Self::AED => "د.إ",
169            Self::SAR => "﷼",
170            Self::TWD => "NT$",
171            Self::VND => "₫",
172            Self::BTC => "₿",
173            Self::ETH => "Ξ",
174            Self::USDC => "USDC",
175            Self::USDT => "USDT",
176        }
177    }
178
179    /// Get the currency name
180    #[must_use]
181    pub const fn name(&self) -> &'static str {
182        match self {
183            Self::USD => "US Dollar",
184            Self::EUR => "Euro",
185            Self::GBP => "British Pound",
186            Self::JPY => "Japanese Yen",
187            Self::CAD => "Canadian Dollar",
188            Self::AUD => "Australian Dollar",
189            Self::CHF => "Swiss Franc",
190            Self::CNY => "Chinese Yuan",
191            Self::HKD => "Hong Kong Dollar",
192            Self::SGD => "Singapore Dollar",
193            Self::SEK => "Swedish Krona",
194            Self::NOK => "Norwegian Krone",
195            Self::DKK => "Danish Krone",
196            Self::NZD => "New Zealand Dollar",
197            Self::MXN => "Mexican Peso",
198            Self::INR => "Indian Rupee",
199            Self::BRL => "Brazilian Real",
200            Self::KRW => "South Korean Won",
201            Self::ZAR => "South African Rand",
202            Self::RUB => "Russian Ruble",
203            Self::TRY => "Turkish Lira",
204            Self::PLN => "Polish Zloty",
205            Self::THB => "Thai Baht",
206            Self::IDR => "Indonesian Rupiah",
207            Self::MYR => "Malaysian Ringgit",
208            Self::PHP => "Philippine Peso",
209            Self::CZK => "Czech Koruna",
210            Self::ILS => "Israeli Shekel",
211            Self::AED => "UAE Dirham",
212            Self::SAR => "Saudi Riyal",
213            Self::TWD => "Taiwan Dollar",
214            Self::VND => "Vietnamese Dong",
215            Self::BTC => "Bitcoin",
216            Self::ETH => "Ethereum",
217            Self::USDC => "USD Coin",
218            Self::USDT => "Tether",
219        }
220    }
221
222    /// Get the number of decimal places for this currency
223    #[must_use]
224    pub const fn decimal_places(&self) -> u8 {
225        match self {
226            // Zero decimal currencies
227            Self::JPY | Self::KRW | Self::VND => 0,
228            // Crypto with 8 decimals
229            Self::BTC => 8,
230            // Crypto with 18 decimals (but we'll use 8 for practical purposes)
231            Self::ETH => 8,
232            // All others use 2 decimals
233            _ => 2,
234        }
235    }
236
237    /// Check if this is a cryptocurrency
238    #[must_use]
239    pub const fn is_crypto(&self) -> bool {
240        matches!(self, Self::BTC | Self::ETH | Self::USDC | Self::USDT)
241    }
242
243    /// Check if this is a fiat currency
244    #[must_use]
245    pub const fn is_fiat(&self) -> bool {
246        !self.is_crypto()
247    }
248
249    /// Get all supported currencies
250    #[must_use]
251    pub fn all() -> Vec<Self> {
252        vec![
253            Self::USD,
254            Self::EUR,
255            Self::GBP,
256            Self::JPY,
257            Self::CAD,
258            Self::AUD,
259            Self::CHF,
260            Self::CNY,
261            Self::HKD,
262            Self::SGD,
263            Self::SEK,
264            Self::NOK,
265            Self::DKK,
266            Self::NZD,
267            Self::MXN,
268            Self::INR,
269            Self::BRL,
270            Self::KRW,
271            Self::ZAR,
272            Self::RUB,
273            Self::TRY,
274            Self::PLN,
275            Self::THB,
276            Self::IDR,
277            Self::MYR,
278            Self::PHP,
279            Self::CZK,
280            Self::ILS,
281            Self::AED,
282            Self::SAR,
283            Self::TWD,
284            Self::VND,
285            Self::BTC,
286            Self::ETH,
287            Self::USDC,
288            Self::USDT,
289        ]
290    }
291}
292
293impl fmt::Display for Currency {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        write!(f, "{}", self.code())
296    }
297}
298
299impl FromStr for Currency {
300    type Err = String;
301
302    fn from_str(s: &str) -> Result<Self, Self::Err> {
303        match s.to_uppercase().as_str() {
304            "USD" => Ok(Self::USD),
305            "EUR" => Ok(Self::EUR),
306            "GBP" => Ok(Self::GBP),
307            "JPY" => Ok(Self::JPY),
308            "CAD" => Ok(Self::CAD),
309            "AUD" => Ok(Self::AUD),
310            "CHF" => Ok(Self::CHF),
311            "CNY" => Ok(Self::CNY),
312            "HKD" => Ok(Self::HKD),
313            "SGD" => Ok(Self::SGD),
314            "SEK" => Ok(Self::SEK),
315            "NOK" => Ok(Self::NOK),
316            "DKK" => Ok(Self::DKK),
317            "NZD" => Ok(Self::NZD),
318            "MXN" => Ok(Self::MXN),
319            "INR" => Ok(Self::INR),
320            "BRL" => Ok(Self::BRL),
321            "KRW" => Ok(Self::KRW),
322            "ZAR" => Ok(Self::ZAR),
323            "RUB" => Ok(Self::RUB),
324            "TRY" => Ok(Self::TRY),
325            "PLN" => Ok(Self::PLN),
326            "THB" => Ok(Self::THB),
327            "IDR" => Ok(Self::IDR),
328            "MYR" => Ok(Self::MYR),
329            "PHP" => Ok(Self::PHP),
330            "CZK" => Ok(Self::CZK),
331            "ILS" => Ok(Self::ILS),
332            "AED" => Ok(Self::AED),
333            "SAR" => Ok(Self::SAR),
334            "TWD" => Ok(Self::TWD),
335            "VND" => Ok(Self::VND),
336            "BTC" => Ok(Self::BTC),
337            "ETH" => Ok(Self::ETH),
338            "USDC" => Ok(Self::USDC),
339            "USDT" => Ok(Self::USDT),
340            _ => Err(format!("Unknown currency code: {s}")),
341        }
342    }
343}
344
345// ============================================================================
346// Money Type
347// ============================================================================
348
349/// Represents a monetary amount with its currency
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351pub struct Money {
352    /// The amount in the smallest unit (e.g., cents for USD)
353    pub amount: Decimal,
354    /// The currency
355    pub currency: Currency,
356}
357
358impl Money {
359    /// Create a new Money instance
360    #[must_use]
361    pub const fn new(amount: Decimal, currency: Currency) -> Self {
362        Self { amount, currency }
363    }
364
365    /// Create Money from a major unit amount (e.g., dollars, not cents)
366    #[must_use]
367    pub const fn from_major(amount: Decimal, currency: Currency) -> Self {
368        Self { amount, currency }
369    }
370
371    /// Create zero money in a currency
372    #[must_use]
373    pub const fn zero(currency: Currency) -> Self {
374        Self { amount: Decimal::ZERO, currency }
375    }
376
377    /// Check if the amount is zero
378    #[must_use]
379    pub const fn is_zero(&self) -> bool {
380        self.amount.is_zero()
381    }
382
383    /// Check if the amount is positive
384    #[must_use]
385    pub const fn is_positive(&self) -> bool {
386        self.amount.is_sign_positive() && !self.amount.is_zero()
387    }
388
389    /// Check if the amount is negative
390    #[must_use]
391    pub const fn is_negative(&self) -> bool {
392        self.amount.is_sign_negative()
393    }
394
395    /// Get the absolute value
396    #[must_use]
397    pub fn abs(&self) -> Self {
398        Self { amount: self.amount.abs(), currency: self.currency }
399    }
400
401    /// Round to the currency's decimal places
402    #[must_use]
403    pub fn round(&self) -> Self {
404        let places = u32::from(self.currency.decimal_places());
405        Self { amount: self.amount.round_dp(places), currency: self.currency }
406    }
407
408    /// Format as a string with symbol
409    #[must_use]
410    pub fn format(&self) -> String {
411        let rounded = self.round();
412        let places = self.currency.decimal_places();
413        if places == 0 {
414            format!("{}{}", self.currency.symbol(), rounded.amount)
415        } else {
416            format!(
417                "{}{}",
418                self.currency.symbol(),
419                Self::format_amount_fixed(rounded.amount, places)
420            )
421        }
422    }
423
424    /// Format as a string with currency code
425    #[must_use]
426    pub fn format_with_code(&self) -> String {
427        let rounded = self.round();
428        let places = self.currency.decimal_places();
429        format!("{} {}", Self::format_amount_fixed(rounded.amount, places), self.currency.code())
430    }
431
432    fn format_amount_fixed(amount: Decimal, places: u8) -> String {
433        if places == 0 {
434            return amount.to_string();
435        }
436
437        let mut s = amount.to_string();
438        let places = places as usize;
439
440        if let Some(dot) = s.find('.') {
441            let fractional_len = s.len().saturating_sub(dot + 1);
442            if fractional_len < places {
443                s.push_str(&"0".repeat(places - fractional_len));
444            } else if fractional_len > places {
445                s.truncate(dot + 1 + places);
446            }
447        } else {
448            s.push('.');
449            s.push_str(&"0".repeat(places));
450        }
451
452        s
453    }
454}
455
456impl Default for Money {
457    fn default() -> Self {
458        Self::zero(Currency::USD)
459    }
460}
461
462impl fmt::Display for Money {
463    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464        write!(f, "{}", self.format())
465    }
466}
467
468// ============================================================================
469// Exchange Rate
470// ============================================================================
471
472/// An exchange rate between two currencies
473#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct ExchangeRate {
475    /// Unique identifier
476    pub id: Uuid,
477    /// Base currency (from)
478    pub base_currency: Currency,
479    /// Quote currency (to)
480    pub quote_currency: Currency,
481    /// Exchange rate (1 base = rate quote)
482    pub rate: Decimal,
483    /// Rate source (e.g., "ECB", "manual", "openexchangerates")
484    pub source: String,
485    /// When this rate was fetched/set
486    pub rate_at: DateTime<Utc>,
487    /// When this record was created
488    pub created_at: DateTime<Utc>,
489    /// When this record was last updated
490    pub updated_at: DateTime<Utc>,
491}
492
493impl ExchangeRate {
494    /// Convert an amount from base to quote currency
495    #[must_use]
496    pub fn convert(&self, amount: Decimal) -> Decimal {
497        amount * self.rate
498    }
499
500    /// Convert an amount from quote to base currency (inverse)
501    #[must_use]
502    pub fn convert_inverse(&self, amount: Decimal) -> Decimal {
503        if self.rate.is_zero() { Decimal::ZERO } else { amount / self.rate }
504    }
505
506    /// Get the inverse rate
507    #[must_use]
508    pub fn inverse(&self) -> Decimal {
509        if self.rate.is_zero() { Decimal::ZERO } else { Decimal::ONE / self.rate }
510    }
511}
512
513// ============================================================================
514// Currency Conversion Request/Result
515// ============================================================================
516
517/// Request to convert money between currencies
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct ConvertCurrency {
520    /// Amount to convert
521    pub amount: Decimal,
522    /// Source currency
523    pub from: Currency,
524    /// Target currency
525    pub to: Currency,
526}
527
528/// Result of a currency conversion
529#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct ConversionResult {
531    /// Original amount
532    pub original_amount: Decimal,
533    /// Original currency
534    pub original_currency: Currency,
535    /// Converted amount
536    pub converted_amount: Decimal,
537    /// Target currency
538    pub target_currency: Currency,
539    /// Exchange rate used
540    pub rate: Decimal,
541    /// Inverse rate
542    pub inverse_rate: Decimal,
543    /// Rate timestamp
544    pub rate_at: DateTime<Utc>,
545}
546
547// ============================================================================
548// Multi-Currency Price
549// ============================================================================
550
551/// A price that can be displayed in multiple currencies
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct MultiCurrencyPrice {
554    /// Base price (source of truth)
555    pub base: Money,
556    /// Prices in other currencies (cached/calculated)
557    pub prices: Vec<Money>,
558}
559
560impl MultiCurrencyPrice {
561    /// Create a new multi-currency price with just the base
562    #[must_use]
563    pub const fn new(base: Money) -> Self {
564        Self { base, prices: Vec::new() }
565    }
566
567    /// Get the price in a specific currency if available
568    #[must_use]
569    pub fn get(&self, currency: Currency) -> Option<&Money> {
570        if self.base.currency == currency {
571            Some(&self.base)
572        } else {
573            self.prices.iter().find(|p| p.currency == currency)
574        }
575    }
576
577    /// Add a price in another currency
578    pub fn add_price(&mut self, price: Money) {
579        // Don't add if it's the base currency or already exists
580        if price.currency != self.base.currency
581            && !self.prices.iter().any(|p| p.currency == price.currency)
582        {
583            self.prices.push(price);
584        }
585    }
586}
587
588// ============================================================================
589// Exchange Rate Management
590// ============================================================================
591
592/// Request to set an exchange rate
593#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct SetExchangeRate {
595    /// Base currency
596    pub base_currency: Currency,
597    /// Quote currency
598    pub quote_currency: Currency,
599    /// Exchange rate
600    pub rate: Decimal,
601    /// Source of the rate
602    pub source: Option<String>,
603}
604
605/// Filter for listing exchange rates
606#[derive(Debug, Clone, Default, Serialize, Deserialize)]
607pub struct ExchangeRateFilter {
608    /// Filter by base currency
609    pub base_currency: Option<Currency>,
610    /// Filter by quote currency
611    pub quote_currency: Option<Currency>,
612    /// Only rates newer than this
613    pub since: Option<DateTime<Utc>>,
614    /// Maximum number of rows to return (server default/cap applies when unset)
615    pub limit: Option<u32>,
616    /// Number of rows to skip
617    pub offset: Option<u32>,
618}
619
620// ============================================================================
621// Store Currency Settings
622// ============================================================================
623
624/// Store-level currency configuration
625#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct StoreCurrencySettings {
627    /// Default/base currency for the store
628    pub base_currency: Currency,
629    /// Currencies enabled for display
630    pub enabled_currencies: Vec<Currency>,
631    /// Whether to auto-convert prices
632    pub auto_convert: bool,
633    /// Rounding mode for conversions
634    pub rounding_mode: RoundingMode,
635}
636
637impl Default for StoreCurrencySettings {
638    fn default() -> Self {
639        Self {
640            base_currency: Currency::USD,
641            enabled_currencies: vec![Currency::USD, Currency::EUR, Currency::GBP],
642            auto_convert: true,
643            rounding_mode: RoundingMode::HalfUp,
644        }
645    }
646}
647
648/// Rounding mode for currency conversions
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
650#[serde(rename_all = "snake_case")]
651#[non_exhaustive]
652pub enum RoundingMode {
653    /// Round half up (standard)
654    #[default]
655    HalfUp,
656    /// Round half down
657    HalfDown,
658    /// Always round up
659    Up,
660    /// Always round down
661    Down,
662    /// Round to nearest even (banker's rounding)
663    HalfEven,
664}
665
666impl fmt::Display for RoundingMode {
667    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
668        match self {
669            Self::HalfUp => write!(f, "half_up"),
670            Self::HalfDown => write!(f, "half_down"),
671            Self::Up => write!(f, "up"),
672            Self::Down => write!(f, "down"),
673            Self::HalfEven => write!(f, "half_even"),
674        }
675    }
676}
677
678impl FromStr for RoundingMode {
679    type Err = String;
680
681    fn from_str(s: &str) -> Result<Self, Self::Err> {
682        match s.trim().to_ascii_lowercase().as_str() {
683            "half_up" | "halfup" | "half-up" => Ok(Self::HalfUp),
684            "half_down" | "halfdown" | "half-down" => Ok(Self::HalfDown),
685            "up" => Ok(Self::Up),
686            "down" => Ok(Self::Down),
687            "half_even" | "halfeven" | "half-even" | "bankers" | "bankers_rounding" => {
688                Ok(Self::HalfEven)
689            }
690            _ => Err(format!("Unknown rounding mode: {s}")),
691        }
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn test_currency_from_str() {
701        assert_eq!(Currency::from_str("USD").unwrap(), Currency::USD);
702        assert_eq!(Currency::from_str("eur").unwrap(), Currency::EUR);
703        assert_eq!(Currency::from_str("Gbp").unwrap(), Currency::GBP);
704        assert!(Currency::from_str("XXX").is_err());
705    }
706
707    #[test]
708    fn test_money_format() {
709        let usd = Money::new(Decimal::from(1234), Currency::USD);
710        assert_eq!(usd.format(), "$1234.00");
711
712        let jpy = Money::new(Decimal::from(1234), Currency::JPY);
713        assert_eq!(jpy.format(), "¥1234");
714    }
715
716    #[test]
717    fn test_exchange_rate_convert() {
718        let rate = ExchangeRate {
719            id: Uuid::new_v4(),
720            base_currency: Currency::USD,
721            quote_currency: Currency::EUR,
722            rate: Decimal::new(85, 2), // 0.85
723            source: "test".into(),
724            rate_at: Utc::now(),
725            created_at: Utc::now(),
726            updated_at: Utc::now(),
727        };
728
729        let result = rate.convert(Decimal::from(100));
730        assert_eq!(result, Decimal::from(85));
731    }
732
733    #[test]
734    fn test_rounding_mode_from_str() {
735        assert_eq!(RoundingMode::from_str("half_up").unwrap(), RoundingMode::HalfUp);
736        assert_eq!(RoundingMode::from_str("HalfDown").unwrap(), RoundingMode::HalfDown);
737        assert_eq!(RoundingMode::from_str("half-even").unwrap(), RoundingMode::HalfEven);
738        assert!(RoundingMode::from_str("nope").is_err());
739    }
740}