548 lines
19 KiB
Rust
548 lines
19 KiB
Rust
use crate::models::currency::{Currency, Price};
|
|
use rust_decimal::Decimal;
|
|
use rust_decimal_macros::dec;
|
|
use std::collections::HashMap;
|
|
use chrono::Utc;
|
|
use actix_session::Session;
|
|
|
|
/// Service for handling currency operations and conversions
|
|
#[derive(Clone)]
|
|
pub struct CurrencyService {
|
|
exchange_rates_cache: HashMap<String, Decimal>,
|
|
last_update: chrono::DateTime<chrono::Utc>,
|
|
default_display_currency: String,
|
|
}
|
|
|
|
impl CurrencyService {
|
|
pub fn new() -> Self {
|
|
let mut service = Self {
|
|
exchange_rates_cache: HashMap::default(),
|
|
last_update: Utc::now(),
|
|
default_display_currency: "MC".to_string(),
|
|
};
|
|
|
|
// MC is now the base currency - no conversion needed
|
|
service.exchange_rates_cache.insert("MC".to_string(), dec!(1.0));
|
|
service.update_exchange_rates();
|
|
service
|
|
}
|
|
|
|
pub fn new_with_config(
|
|
_cache_duration_minutes: u64,
|
|
_base_currency: String,
|
|
auto_update: bool,
|
|
fallback_rates: HashMap<String, Decimal>,
|
|
) -> Self {
|
|
let mut service = Self {
|
|
exchange_rates_cache: fallback_rates,
|
|
last_update: Utc::now(),
|
|
default_display_currency: "MC".to_string(),
|
|
};
|
|
|
|
if auto_update {
|
|
service.update_exchange_rates();
|
|
}
|
|
|
|
service
|
|
}
|
|
|
|
pub fn new_with_display_config(
|
|
_cache_duration_minutes: u64,
|
|
_base_currency: String,
|
|
display_currency: String,
|
|
auto_update: bool,
|
|
fallback_rates: HashMap<String, Decimal>,
|
|
) -> Self {
|
|
let mut service = Self {
|
|
exchange_rates_cache: fallback_rates,
|
|
last_update: Utc::now(),
|
|
default_display_currency: display_currency,
|
|
};
|
|
|
|
if auto_update {
|
|
service.update_exchange_rates();
|
|
}
|
|
|
|
service
|
|
}
|
|
|
|
pub fn builder() -> crate::models::builders::CurrencyServiceBuilder {
|
|
crate::models::builders::CurrencyServiceBuilder::new()
|
|
}
|
|
|
|
/// Get all supported currencies
|
|
pub fn get_supported_currencies(&self) -> Vec<Currency> {
|
|
// Return standard supported currencies with MC as base currency
|
|
vec![
|
|
Currency {
|
|
code: "MC".to_string(),
|
|
name: "Mycelium Credit".to_string(),
|
|
symbol: "MC".to_string(),
|
|
currency_type: crate::models::currency::CurrencyType::Custom("credits".to_string()),
|
|
exchange_rate_to_base: dec!(1.0), // MC is the base currency
|
|
is_base_currency: true,
|
|
decimal_places: 2,
|
|
is_active: true,
|
|
provider_config: None,
|
|
last_updated: chrono::Utc::now(),
|
|
},
|
|
Currency {
|
|
code: "USD".to_string(),
|
|
name: "US Dollar".to_string(),
|
|
symbol: "$".to_string(),
|
|
currency_type: crate::models::currency::CurrencyType::Fiat,
|
|
exchange_rate_to_base: dec!(1.0), // 1 USD = 1 MC (parity for UX preservation)
|
|
is_base_currency: false,
|
|
decimal_places: 2,
|
|
is_active: true,
|
|
provider_config: None,
|
|
last_updated: chrono::Utc::now(),
|
|
},
|
|
Currency {
|
|
code: "EUR".to_string(),
|
|
name: "Euro".to_string(),
|
|
symbol: "€".to_string(),
|
|
currency_type: crate::models::currency::CurrencyType::Fiat,
|
|
exchange_rate_to_base: dec!(0.85), // 1 EUR = 0.85 MC
|
|
is_base_currency: false,
|
|
decimal_places: 2,
|
|
is_active: true,
|
|
provider_config: None,
|
|
last_updated: chrono::Utc::now(),
|
|
},
|
|
Currency {
|
|
code: "CAD".to_string(),
|
|
name: "Canadian Dollar".to_string(),
|
|
symbol: "C$".to_string(),
|
|
currency_type: crate::models::currency::CurrencyType::Fiat,
|
|
exchange_rate_to_base: dec!(1.35), // 1 CAD = 1.35 MC
|
|
is_base_currency: false,
|
|
decimal_places: 2,
|
|
is_active: true,
|
|
provider_config: None,
|
|
last_updated: chrono::Utc::now(),
|
|
},
|
|
Currency {
|
|
code: "TFT".to_string(),
|
|
name: "Mycelium Token".to_string(),
|
|
symbol: "TFT".to_string(),
|
|
currency_type: crate::models::currency::CurrencyType::Token,
|
|
exchange_rate_to_base: dec!(0.05), // 1 TFT = 0.05 MC
|
|
is_base_currency: false,
|
|
decimal_places: 3,
|
|
is_active: true,
|
|
provider_config: None,
|
|
last_updated: chrono::Utc::now(),
|
|
},
|
|
Currency {
|
|
code: "AED".to_string(),
|
|
name: "UAE Dirham".to_string(),
|
|
symbol: "د.إ".to_string(),
|
|
currency_type: crate::models::currency::CurrencyType::Fiat,
|
|
exchange_rate_to_base: dec!(3.67), // 1 AED = 3.67 MC
|
|
is_base_currency: false,
|
|
decimal_places: 2,
|
|
is_active: true,
|
|
provider_config: None,
|
|
last_updated: chrono::Utc::now(),
|
|
},
|
|
]
|
|
}
|
|
|
|
/// Get currency by code
|
|
pub fn get_currency(&self, code: &str) -> Option<Currency> {
|
|
self.get_supported_currencies()
|
|
.into_iter()
|
|
.find(|c| c.code == code)
|
|
}
|
|
|
|
/// Get base currency
|
|
pub fn get_base_currency(&self) -> Currency {
|
|
Currency {
|
|
code: "MC".to_string(),
|
|
name: "Mycelium Credit".to_string(),
|
|
symbol: "MC".to_string(),
|
|
currency_type: crate::models::currency::CurrencyType::Custom("credits".to_string()),
|
|
exchange_rate_to_base: dec!(1.0),
|
|
is_base_currency: true,
|
|
decimal_places: 2,
|
|
is_active: true,
|
|
provider_config: None,
|
|
last_updated: chrono::Utc::now(),
|
|
}
|
|
}
|
|
|
|
/// Convert amount from one currency to another
|
|
pub fn convert_amount(
|
|
&self,
|
|
amount: Decimal,
|
|
from_currency: &str,
|
|
to_currency: &str,
|
|
) -> Result<Decimal, String> {
|
|
if from_currency == to_currency {
|
|
return Ok(amount);
|
|
}
|
|
|
|
let base_currency_code = "MC"; // Use MC as base currency
|
|
|
|
// Convert to base currency first if needed
|
|
let base_amount = if from_currency == base_currency_code {
|
|
amount
|
|
} else {
|
|
let from_rate = self.get_exchange_rate_to_base(from_currency)?;
|
|
amount / from_rate
|
|
};
|
|
|
|
// Convert from base currency to target currency
|
|
if to_currency == base_currency_code {
|
|
Ok(base_amount)
|
|
} else {
|
|
let to_rate = self.get_exchange_rate_to_base(to_currency)?;
|
|
Ok(base_amount * to_rate)
|
|
}
|
|
}
|
|
|
|
/// Get exchange rate from base currency to target currency
|
|
pub fn get_exchange_rate_to_base(&self, currency_code: &str) -> Result<Decimal, String> {
|
|
let base_currency_code = "MC"; // Use MC as base currency
|
|
|
|
if currency_code == base_currency_code {
|
|
return Ok(dec!(1.0));
|
|
}
|
|
|
|
if let Some(rate) = self.exchange_rates_cache.get(currency_code) {
|
|
Ok(*rate)
|
|
} else if let Some(currency) = self.get_currency(currency_code) {
|
|
Ok(currency.exchange_rate_to_base)
|
|
} else {
|
|
Err(format!("Currency {} not found", currency_code))
|
|
}
|
|
}
|
|
|
|
/// Create a Price object with conversion
|
|
pub fn create_price(
|
|
&self,
|
|
base_amount: Decimal,
|
|
base_currency: &str,
|
|
display_currency: &str,
|
|
) -> Result<Price, String> {
|
|
let conversion_rate = if base_currency == display_currency {
|
|
dec!(1.0)
|
|
} else {
|
|
self.convert_amount(dec!(1.0), base_currency, display_currency)?
|
|
};
|
|
|
|
let mut price = Price::new(
|
|
base_amount,
|
|
base_currency.to_string(),
|
|
display_currency.to_string(),
|
|
conversion_rate,
|
|
);
|
|
|
|
// Update formatted display with proper currency symbol
|
|
let formatted_display = self.format_price(price.display_amount, display_currency)?;
|
|
price.update_formatted_display(formatted_display);
|
|
|
|
Ok(price)
|
|
}
|
|
|
|
/// Format price with currency symbol
|
|
pub fn format_price(
|
|
&self,
|
|
amount: Decimal,
|
|
currency_code: &str,
|
|
) -> Result<String, String> {
|
|
if let Some(currency) = self.get_currency(currency_code) {
|
|
Ok(currency.format_amount(amount))
|
|
} else {
|
|
Err(format!("Currency {} not found", currency_code))
|
|
}
|
|
}
|
|
|
|
/// Update exchange rates with standard rates
|
|
pub fn update_exchange_rates(&mut self) {
|
|
// Use standard exchange rates without mock data
|
|
let currencies = self.get_supported_currencies();
|
|
|
|
for currency in currencies {
|
|
if !currency.is_base_currency {
|
|
// Use the currency's exchange rate directly
|
|
let new_rate = currency.exchange_rate_to_base;
|
|
self.exchange_rates_cache.insert(currency.code.clone(), new_rate);
|
|
}
|
|
}
|
|
|
|
self.last_update = Utc::now();
|
|
}
|
|
|
|
/// Get the default display currency for this service instance
|
|
pub fn get_default_display_currency(&self) -> &str {
|
|
&self.default_display_currency
|
|
}
|
|
|
|
/// Get user's preferred currency from session or persistent data
|
|
pub fn get_user_preferred_currency(&self, session: &Session) -> String {
|
|
// First check session for temporary preference
|
|
if let Ok(Some(currency)) = session.get::<String>("preferred_currency") {
|
|
return currency;
|
|
}
|
|
|
|
// Then check persistent user data
|
|
if let Ok(Some(user_email)) = session.get::<String>("user_email") {
|
|
if let Some(persistent_data) = crate::services::user_persistence::UserPersistence::load_user_data(&user_email) {
|
|
if let Some(display_currency) = persistent_data.display_currency {
|
|
return display_currency;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to service default, then system default
|
|
if !self.default_display_currency.is_empty() {
|
|
self.default_display_currency.clone()
|
|
} else {
|
|
"MC".to_string() // Default to MC when no preference is set
|
|
}
|
|
}
|
|
|
|
/// Set user's preferred currency in session and persistent data
|
|
pub fn set_user_preferred_currency(&self, session: &Session, currency_code: String) -> Result<(), String> {
|
|
if self.get_currency(¤cy_code).is_none() {
|
|
return Err(format!("Currency {} is not supported", currency_code));
|
|
}
|
|
|
|
// Set in session for immediate use
|
|
session.insert("preferred_currency", currency_code.clone())
|
|
.map_err(|e| format!("Failed to set currency preference in session: {}", e))?;
|
|
|
|
// Save to persistent data if user is logged in
|
|
if let Ok(Some(user_email)) = session.get::<String>("user_email") {
|
|
let mut persistent_data = crate::services::user_persistence::UserPersistence::load_user_data(&user_email)
|
|
.unwrap_or_else(|| crate::services::user_persistence::UserPersistentData {
|
|
user_email: user_email.clone(),
|
|
..Default::default()
|
|
});
|
|
|
|
persistent_data.display_currency = Some(currency_code);
|
|
|
|
if let Err(e) = crate::services::user_persistence::UserPersistence::save_user_data(&persistent_data) {
|
|
// Don't fail the operation, session preference is still set
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get all exchange rates relative to base currency
|
|
pub fn get_all_exchange_rates(&self) -> HashMap<String, Decimal> {
|
|
let mut rates = HashMap::default();
|
|
let base_currency_code = "MC".to_string(); // Use MC as base currency
|
|
|
|
// Base currency always has rate 1.0
|
|
rates.insert(base_currency_code.clone(), dec!(1.0));
|
|
|
|
// Add cached rates
|
|
for (currency, rate) in &self.exchange_rates_cache {
|
|
rates.insert(currency.clone(), *rate);
|
|
}
|
|
|
|
// Add any missing currencies from static config
|
|
for currency in self.get_supported_currencies() {
|
|
if !rates.contains_key(¤cy.code) && !currency.is_base_currency {
|
|
rates.insert(currency.code.clone(), currency.exchange_rate_to_base);
|
|
}
|
|
}
|
|
|
|
rates
|
|
}
|
|
|
|
/// Check if exchange rates need updating
|
|
pub fn should_update_rates(&self) -> bool {
|
|
// Check if rates need updating based on time interval (15 minutes default)
|
|
let update_interval = chrono::Duration::minutes(15);
|
|
Utc::now().signed_duration_since(self.last_update) > update_interval
|
|
}
|
|
|
|
/// Get currency statistics for admin/debug purposes
|
|
pub fn get_currency_stats(&self) -> HashMap<String, serde_json::Value> {
|
|
let mut stats = HashMap::default();
|
|
|
|
stats.insert("total_currencies".to_string(),
|
|
serde_json::Value::Number(serde_json::Number::from(self.get_supported_currencies().len())));
|
|
|
|
stats.insert("base_currency".to_string(),
|
|
serde_json::Value::String("MC".to_string()));
|
|
|
|
stats.insert("last_update".to_string(),
|
|
serde_json::Value::String(self.last_update.to_rfc3339()));
|
|
|
|
stats.insert("cached_rates_count".to_string(),
|
|
serde_json::Value::Number(serde_json::Number::from(self.exchange_rates_cache.len())));
|
|
|
|
let rate_values: Vec<serde_json::Value> = self.exchange_rates_cache.iter()
|
|
.map(|(currency, rate)| {
|
|
serde_json::json!({
|
|
"currency": currency,
|
|
"rate": rate.to_string()
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
stats.insert("current_rates".to_string(), serde_json::Value::Array(rate_values));
|
|
|
|
stats
|
|
}
|
|
|
|
/// Convert product prices for display in user's preferred currency
|
|
pub fn convert_product_prices(
|
|
&self,
|
|
products: &[crate::models::product::Product],
|
|
display_currency: &str,
|
|
) -> Result<Vec<(String, Price)>, String> {
|
|
let mut converted_prices = Vec::default();
|
|
|
|
for product in products {
|
|
let price = self.create_price(
|
|
product.base_price,
|
|
&product.base_currency,
|
|
display_currency,
|
|
)?;
|
|
converted_prices.push((product.id.clone(), price));
|
|
}
|
|
|
|
Ok(converted_prices)
|
|
}
|
|
|
|
/// Get currency display info for frontend
|
|
pub fn get_currency_display_info(&self) -> Vec<serde_json::Value> {
|
|
self.get_supported_currencies().iter()
|
|
.filter(|c| c.is_active)
|
|
.map(|currency| {
|
|
serde_json::json!({
|
|
"code": currency.code,
|
|
"name": currency.name,
|
|
"symbol": currency.symbol,
|
|
"type": currency.currency_type,
|
|
"decimal_places": currency.decimal_places,
|
|
"is_base": currency.is_base_currency
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Convert USD amount to user's preferred display currency
|
|
pub fn convert_usd_to_display_currency(
|
|
&self,
|
|
usd_amount: Decimal,
|
|
session: &Session,
|
|
) -> Result<(Decimal, String), String> {
|
|
let display_currency = self.get_user_preferred_currency(session);
|
|
|
|
if display_currency == "USD" {
|
|
Ok((usd_amount, "USD".to_string()))
|
|
} else {
|
|
let converted_amount = self.convert_amount(usd_amount, "USD", &display_currency)?;
|
|
Ok((converted_amount, display_currency))
|
|
}
|
|
}
|
|
|
|
/// Convert user's display currency amount to USD
|
|
pub fn convert_display_currency_to_usd(
|
|
&self,
|
|
amount: Decimal,
|
|
session: &Session,
|
|
) -> Result<Decimal, String> {
|
|
let display_currency = self.get_user_preferred_currency(session);
|
|
|
|
if display_currency == "USD" {
|
|
Ok(amount)
|
|
} else {
|
|
self.convert_amount(amount, &display_currency, "USD")
|
|
}
|
|
}
|
|
|
|
/// Get formatted price in user's preferred currency
|
|
pub fn get_formatted_price_for_user(
|
|
&self,
|
|
usd_amount: Decimal,
|
|
session: &Session,
|
|
) -> Result<String, String> {
|
|
let (display_amount, display_currency) = self.convert_usd_to_display_currency(usd_amount, session)?;
|
|
self.format_price(display_amount, &display_currency)
|
|
}
|
|
|
|
/// Get quick top-up suggestions based on user's preferred currency
|
|
pub fn get_suggested_topup_amounts(&self, session: &Session) -> Vec<Decimal> {
|
|
let display_currency = self.get_user_preferred_currency(session);
|
|
|
|
match display_currency.as_str() {
|
|
"USD" => vec![
|
|
Decimal::from(10), // $10
|
|
Decimal::from(25), // $25
|
|
Decimal::from(50), // $50
|
|
Decimal::from(100), // $100
|
|
],
|
|
"EUR" => vec![
|
|
Decimal::from(10),
|
|
Decimal::from(25),
|
|
Decimal::from(50),
|
|
Decimal::from(100),
|
|
],
|
|
"GBP" => vec![
|
|
Decimal::from(10),
|
|
Decimal::from(20),
|
|
Decimal::from(50),
|
|
Decimal::from(100),
|
|
],
|
|
_ => vec![ // Default USD amounts
|
|
Decimal::from(10),
|
|
Decimal::from(20),
|
|
Decimal::from(50),
|
|
Decimal::from(100),
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for CurrencyService {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Utility functions for currency operations
|
|
pub mod utils {
|
|
use super::*;
|
|
|
|
/// Format amount with proper decimal places for currency
|
|
pub fn format_amount_with_currency(
|
|
amount: Decimal,
|
|
currency: &Currency,
|
|
) -> String {
|
|
format!("{} {}",
|
|
amount.round_dp(currency.decimal_places as u32),
|
|
currency.symbol
|
|
)
|
|
}
|
|
|
|
/// Parse currency amount from string
|
|
pub fn parse_currency_amount(amount_str: &str) -> Result<Decimal, String> {
|
|
amount_str.parse::<Decimal>()
|
|
.map_err(|e| format!("Invalid amount format: {}", e))
|
|
}
|
|
|
|
/// Validate currency code format
|
|
pub fn is_valid_currency_code(code: &str) -> bool {
|
|
code.len() == 3 && code.chars().all(|c| c.is_ascii_uppercase())
|
|
}
|
|
|
|
/// Get currency type display name
|
|
pub fn get_currency_type_display(currency_type: &crate::models::currency::CurrencyType) -> &'static str {
|
|
match currency_type {
|
|
crate::models::currency::CurrencyType::Fiat => "Fiat Currency",
|
|
crate::models::currency::CurrencyType::Cryptocurrency => "Cryptocurrency",
|
|
crate::models::currency::CurrencyType::Token => "Token",
|
|
crate::models::currency::CurrencyType::Points => "Loyalty Points",
|
|
crate::models::currency::CurrencyType::Custom(_) => "Custom Currency",
|
|
}
|
|
}
|
|
} |