1use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::str::FromStr;
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "UPPERCASE")]
15#[non_exhaustive]
16pub enum Currency {
17 #[default]
19 USD,
20 EUR,
22 GBP,
24 JPY,
26 CAD,
28 AUD,
30 CHF,
32 CNY,
34 HKD,
36 SGD,
38 SEK,
40 NOK,
42 DKK,
44 NZD,
46 MXN,
48 INR,
50 BRL,
52 KRW,
54 ZAR,
56 RUB,
58 TRY,
60 PLN,
62 THB,
64 IDR,
66 MYR,
68 PHP,
70 CZK,
72 ILS,
74 AED,
76 SAR,
78 TWD,
80 VND,
82 BTC,
84 ETH,
86 USDC,
88 USDT,
90}
91
92impl Currency {
93 #[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 #[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 #[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 #[must_use]
224 pub const fn decimal_places(&self) -> u8 {
225 match self {
226 Self::JPY | Self::KRW | Self::VND => 0,
228 Self::BTC => 8,
230 Self::ETH => 8,
232 _ => 2,
234 }
235 }
236
237 #[must_use]
239 pub const fn is_crypto(&self) -> bool {
240 matches!(self, Self::BTC | Self::ETH | Self::USDC | Self::USDT)
241 }
242
243 #[must_use]
245 pub const fn is_fiat(&self) -> bool {
246 !self.is_crypto()
247 }
248
249 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351pub struct Money {
352 pub amount: Decimal,
354 pub currency: Currency,
356}
357
358impl Money {
359 #[must_use]
361 pub const fn new(amount: Decimal, currency: Currency) -> Self {
362 Self { amount, currency }
363 }
364
365 #[must_use]
367 pub const fn from_major(amount: Decimal, currency: Currency) -> Self {
368 Self { amount, currency }
369 }
370
371 #[must_use]
373 pub const fn zero(currency: Currency) -> Self {
374 Self { amount: Decimal::ZERO, currency }
375 }
376
377 #[must_use]
379 pub const fn is_zero(&self) -> bool {
380 self.amount.is_zero()
381 }
382
383 #[must_use]
385 pub const fn is_positive(&self) -> bool {
386 self.amount.is_sign_positive() && !self.amount.is_zero()
387 }
388
389 #[must_use]
391 pub const fn is_negative(&self) -> bool {
392 self.amount.is_sign_negative()
393 }
394
395 #[must_use]
397 pub fn abs(&self) -> Self {
398 Self { amount: self.amount.abs(), currency: self.currency }
399 }
400
401 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct ExchangeRate {
475 pub id: Uuid,
477 pub base_currency: Currency,
479 pub quote_currency: Currency,
481 pub rate: Decimal,
483 pub source: String,
485 pub rate_at: DateTime<Utc>,
487 pub created_at: DateTime<Utc>,
489 pub updated_at: DateTime<Utc>,
491}
492
493impl ExchangeRate {
494 #[must_use]
496 pub fn convert(&self, amount: Decimal) -> Decimal {
497 amount * self.rate
498 }
499
500 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct ConvertCurrency {
520 pub amount: Decimal,
522 pub from: Currency,
524 pub to: Currency,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct ConversionResult {
531 pub original_amount: Decimal,
533 pub original_currency: Currency,
535 pub converted_amount: Decimal,
537 pub target_currency: Currency,
539 pub rate: Decimal,
541 pub inverse_rate: Decimal,
543 pub rate_at: DateTime<Utc>,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct MultiCurrencyPrice {
554 pub base: Money,
556 pub prices: Vec<Money>,
558}
559
560impl MultiCurrencyPrice {
561 #[must_use]
563 pub const fn new(base: Money) -> Self {
564 Self { base, prices: Vec::new() }
565 }
566
567 #[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 pub fn add_price(&mut self, price: Money) {
579 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#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct SetExchangeRate {
595 pub base_currency: Currency,
597 pub quote_currency: Currency,
599 pub rate: Decimal,
601 pub source: Option<String>,
603}
604
605#[derive(Debug, Clone, Default, Serialize, Deserialize)]
607pub struct ExchangeRateFilter {
608 pub base_currency: Option<Currency>,
610 pub quote_currency: Option<Currency>,
612 pub since: Option<DateTime<Utc>>,
614 pub limit: Option<u32>,
616 pub offset: Option<u32>,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct StoreCurrencySettings {
627 pub base_currency: Currency,
629 pub enabled_currencies: Vec<Currency>,
631 pub auto_convert: bool,
633 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
650#[serde(rename_all = "snake_case")]
651#[non_exhaustive]
652pub enum RoundingMode {
653 #[default]
655 HalfUp,
656 HalfDown,
658 Up,
660 Down,
662 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), 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}