1143 lines
46 KiB
Rust
1143 lines
46 KiB
Rust
use actix_web::{web, Result, Responder};
|
|
use actix_session::Session;
|
|
use serde::{Deserialize, Serialize};
|
|
use crate::models::user::{User, Transaction, TransactionType, TransactionStatus, UserActivity, ActivityType, ActivityImportance};
|
|
use crate::config::get_app_config;
|
|
use crate::services::user_persistence::{UserPersistentData, UserPersistence};
|
|
use crate::services::currency::CurrencyService;
|
|
use crate::utils::render_template;
|
|
use crate::utils::ResponseBuilder;
|
|
use tera::Tera;
|
|
use chrono::Utc;
|
|
use rust_decimal::Decimal;
|
|
use rust_decimal_macros::dec;
|
|
|
|
/// Controller for handling wallet operations
|
|
pub struct WalletController;
|
|
|
|
impl WalletController {
|
|
/// Check if user is authenticated, redirect to register if not
|
|
fn check_authentication(session: &Session) -> Result<(), actix_web::HttpResponse> {
|
|
match session.get::<String>("user") {
|
|
Ok(Some(_)) => Ok(()),
|
|
_ => Err(ResponseBuilder::redirect("/register").build().unwrap())
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// New Credits request structs
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct BuyCreditsRequest {
|
|
pub amount: Decimal,
|
|
pub payment_method: String, // "credit_card", "paypal", "bank_transfer"
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SellCreditsRequest {
|
|
pub amount: Decimal,
|
|
pub currency: String, // "USD", "EUR", "GBP"
|
|
pub payout_method: String, // "bank", "card"
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct TransferCreditsRequest {
|
|
pub to_user: String,
|
|
pub amount: Decimal,
|
|
pub note: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ConfigureAutoTopUpRequest {
|
|
pub enabled: bool,
|
|
pub threshold_amount: Decimal,
|
|
pub topup_amount: Decimal,
|
|
pub payment_method_id: String,
|
|
pub daily_limit: Option<Decimal>,
|
|
pub monthly_limit: Option<Decimal>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct WalletResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub transaction_id: Option<String>,
|
|
pub new_balance: Option<Decimal>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct WalletInfo {
|
|
pub balance: Decimal,
|
|
pub currency: String,
|
|
pub recent_transactions: Vec<Transaction>,
|
|
}
|
|
|
|
impl WalletController {
|
|
/// Enhanced user loading with persistent data only (no mock data)
|
|
pub async fn load_user_with_persistent_data(session: &Session, req_id: Option<&str>) -> Option<(User, crate::services::user_persistence::UserPersistentData)> {
|
|
// Get basic user data
|
|
let user_json = session.get::<String>("user").ok()??;
|
|
let mut user: User = serde_json::from_str(&user_json).ok()?;
|
|
|
|
// Get user email for loading persistent data
|
|
let user_email = session.get::<String>("user_email").ok()??;
|
|
|
|
// Load persistent data (no mock data)
|
|
let persistent_data = UserPersistence::load_user_data_locked(&user_email, req_id).await
|
|
.unwrap_or_else(|| UserPersistentData {
|
|
user_email: user_email.clone(),
|
|
wallet_balance_usd: dec!(0), // New users start with 0 USD
|
|
..Default::default()
|
|
});
|
|
|
|
// Using persistent data only - no mock data needed
|
|
|
|
Some((user, persistent_data))
|
|
}
|
|
|
|
/// Legacy function for backward compatibility - now uses persistent data
|
|
pub async fn load_user_with_session_data(session: &Session, req_id: Option<&str>) -> Option<User> {
|
|
if let Some((user, _)) = Self::load_user_with_persistent_data(session, req_id).await {
|
|
Some(user)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Credit USD to a recipient user (for transfers)
|
|
async fn credit_recipient(recipient_email: &str, sender_email: &str, amount: Decimal, transaction_id: &str, req_id: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
|
use crate::services::user_persistence::UserPersistence;
|
|
use chrono::Utc;
|
|
|
|
// Load existing recipient's persistent data (do NOT auto-create)
|
|
let mut recipient_data = match UserPersistence::load_user_data_locked(recipient_email, req_id).await {
|
|
Some(data) => data,
|
|
None => {
|
|
// Recipient does not exist in marketplace; reject transfer
|
|
return Err("Recipient user not found".into());
|
|
}
|
|
};
|
|
|
|
// Add the received amount
|
|
recipient_data.wallet_balance_usd += amount;
|
|
|
|
// Create a receive transaction
|
|
let receive_transaction = Transaction {
|
|
id: format!("{}-receive", transaction_id),
|
|
user_id: recipient_email.to_string(),
|
|
transaction_type: TransactionType::Earning { source: "Transfer Received".to_string() },
|
|
amount,
|
|
currency: Some("USD".to_string()),
|
|
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
|
|
amount_usd: Some(amount),
|
|
description: Some(format!("Transfer received from {}", sender_email)),
|
|
reference_id: Some(format!("transfer-receive-{}", uuid::Uuid::new_v4())),
|
|
metadata: None,
|
|
timestamp: Utc::now(),
|
|
status: TransactionStatus::Completed,
|
|
};
|
|
|
|
// Add transaction to history
|
|
recipient_data.transactions.push(receive_transaction);
|
|
|
|
// Save recipient's updated data
|
|
UserPersistence::save_user_data_locked(&recipient_data, req_id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Render the wallet page within dashboard layout
|
|
pub async fn dashboard_wallet_page(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
|
|
let mut ctx = crate::models::builders::ContextBuilder::new()
|
|
.build();
|
|
|
|
// Check if user is logged in
|
|
if session.get::<String>("user").unwrap_or(None).is_none() {
|
|
// User is not logged in, show welcome page with login/register options
|
|
ctx.insert("active_page", "dashboard");
|
|
ctx.insert("active_section", "wallet");
|
|
let config = get_app_config();
|
|
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
|
|
return render_template(&tmpl, "dashboard/welcome.html", &ctx);
|
|
}
|
|
ctx.insert("active_page", "dashboard");
|
|
ctx.insert("active_section", "wallet");
|
|
|
|
let config = get_app_config();
|
|
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
|
|
|
|
// Load user with persistent data only (no mock data)
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
if let Some((user, persistent_data)) = Self::load_user_with_persistent_data(&session, Some(&req_id)).await {
|
|
if let Ok(Some(user_json)) = session.get::<String>("user") {
|
|
ctx.insert("user_json", &user_json);
|
|
}
|
|
ctx.insert("user", &user);
|
|
|
|
// Currency handling: determine user's preferred currency and convert amounts for display
|
|
let currency_service = CurrencyService::new();
|
|
let mut display_currency = currency_service.get_user_preferred_currency(&session);
|
|
// Validate currency; fallback to USD if unsupported
|
|
let (currency, effective_currency) = match currency_service.get_currency(&display_currency) {
|
|
Some(c) => (c, display_currency.clone()),
|
|
None => {
|
|
let mc = currency_service.get_currency("MC").expect("MC currency must be available");
|
|
display_currency = "MC".to_string();
|
|
(mc, "MC".to_string())
|
|
}
|
|
};
|
|
|
|
// Convert wallet balance (stored in USD) to display currency
|
|
let wallet_balance_display = if effective_currency == "USD" {
|
|
persistent_data.wallet_balance_usd
|
|
} else {
|
|
currency_service
|
|
.convert_amount(persistent_data.wallet_balance_usd, "USD", &effective_currency)
|
|
.unwrap_or(Decimal::ZERO)
|
|
};
|
|
|
|
ctx.insert("wallet_balance", &wallet_balance_display);
|
|
ctx.insert("display_currency", &effective_currency);
|
|
ctx.insert("currency_symbol", ¤cy.symbol);
|
|
|
|
// Extract owned products and active rentals from persistent data
|
|
ctx.insert("owned_products", &persistent_data.active_product_rentals);
|
|
ctx.insert("active_rentals", &persistent_data.node_rentals);
|
|
|
|
// Extract and format transaction history from persistent data
|
|
let mut sorted_transactions = persistent_data.transactions.clone();
|
|
sorted_transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
|
|
|
let formatted_transactions: Vec<serde_json::Value> = sorted_transactions
|
|
.iter()
|
|
.map(|t| {
|
|
// Convert transaction amount (stored in USD) to display currency
|
|
let amount_display = if effective_currency == "USD" {
|
|
t.amount
|
|
} else {
|
|
currency_service
|
|
.convert_amount(t.amount, "USD", &effective_currency)
|
|
.unwrap_or(Decimal::ZERO)
|
|
};
|
|
serde_json::json!({
|
|
"id": t.id,
|
|
"user_id": t.user_id,
|
|
"transaction_type": t.transaction_type,
|
|
"amount": amount_display,
|
|
"status": t.status,
|
|
"formatted_timestamp": t.timestamp.format("%Y-%m-%d %H:%M").to_string(),
|
|
"timestamp": t.timestamp.to_rfc3339()
|
|
})
|
|
})
|
|
.collect();
|
|
ctx.insert("transaction_history", &formatted_transactions);
|
|
}
|
|
|
|
render_template(&tmpl, "dashboard/wallet.html", &ctx)
|
|
}
|
|
|
|
/// Render the wallet page (legacy standalone route - kept for compatibility)
|
|
pub async fn wallet_page(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
|
|
let mut ctx = crate::models::builders::ContextBuilder::new()
|
|
.build();
|
|
ctx.insert("active_page", "wallet");
|
|
|
|
let config = get_app_config();
|
|
ctx.insert("gitea_enabled", &config.is_gitea_enabled());
|
|
|
|
// Load user with persistent data only (no mock data)
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
if let Some((user, persistent_data)) = Self::load_user_with_persistent_data(&session, Some(&req_id)).await {
|
|
if let Ok(Some(user_json)) = session.get::<String>("user") {
|
|
ctx.insert("user_json", &user_json);
|
|
}
|
|
ctx.insert("user", &user);
|
|
|
|
// Determine preferred currency and convert values for display
|
|
let currency_service = CurrencyService::new();
|
|
let mut display_currency = currency_service.get_user_preferred_currency(&session);
|
|
let (currency, effective_currency) = match currency_service.get_currency(&display_currency) {
|
|
Some(c) => (c, display_currency.clone()),
|
|
None => {
|
|
let mc = currency_service.get_currency("MC").expect("MC currency must be available");
|
|
display_currency = "MC".to_string();
|
|
(mc, "MC".to_string())
|
|
}
|
|
};
|
|
|
|
let wallet_balance_display = if effective_currency == "USD" {
|
|
persistent_data.wallet_balance_usd
|
|
} else {
|
|
currency_service
|
|
.convert_amount(persistent_data.wallet_balance_usd, "USD", &effective_currency)
|
|
.unwrap_or(Decimal::ZERO)
|
|
};
|
|
|
|
ctx.insert("wallet_balance", &wallet_balance_display);
|
|
ctx.insert("display_currency", &effective_currency);
|
|
ctx.insert("currency_symbol", ¤cy.symbol);
|
|
|
|
// Extract owned products and active rentals from persistent data
|
|
ctx.insert("owned_products", &persistent_data.active_product_rentals);
|
|
ctx.insert("active_rentals", &persistent_data.node_rentals);
|
|
|
|
// Extract and format transaction history from persistent data
|
|
let mut sorted_transactions = persistent_data.transactions.clone();
|
|
sorted_transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
|
|
|
let formatted_transactions: Vec<serde_json::Value> = sorted_transactions
|
|
.iter()
|
|
.map(|t| {
|
|
let amount_display = if effective_currency == "USD" {
|
|
t.amount
|
|
} else {
|
|
currency_service
|
|
.convert_amount(t.amount, "USD", &effective_currency)
|
|
.unwrap_or(Decimal::ZERO)
|
|
};
|
|
serde_json::json!({
|
|
"id": t.id,
|
|
"user_id": t.user_id,
|
|
"transaction_type": t.transaction_type,
|
|
"amount": amount_display,
|
|
"status": t.status,
|
|
"formatted_timestamp": t.timestamp.format("%Y-%m-%d %H:%M").to_string(),
|
|
"timestamp": t.timestamp.to_rfc3339()
|
|
})
|
|
})
|
|
.collect();
|
|
ctx.insert("transaction_history", &formatted_transactions);
|
|
}
|
|
|
|
render_template(&tmpl, "wallet/index.html", &ctx)
|
|
}
|
|
|
|
|
|
|
|
// NEW CREDITS API METHODS
|
|
|
|
/// Buy Credits (USD-based)
|
|
pub async fn buy_credits(
|
|
request: web::Json<BuyCreditsRequest>,
|
|
session: Session,
|
|
) -> Result<impl Responder> {
|
|
// Load user with session data
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
let _user = match Self::load_user_with_session_data(&session, Some(&req_id)).await {
|
|
Some(u) => u,
|
|
None => return ResponseBuilder::unauthorized()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "User not authenticated".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build(),
|
|
};
|
|
|
|
let user_email = session.get::<String>("user_email")?.unwrap();
|
|
|
|
// Validate amount
|
|
if request.amount <= dec!(0) {
|
|
return ResponseBuilder::bad_request()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "Amount must be greater than 0".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build();
|
|
}
|
|
|
|
if request.amount > dec!(10000) {
|
|
return ResponseBuilder::bad_request()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "Maximum purchase amount is $10,000".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build();
|
|
}
|
|
|
|
// Create transaction
|
|
let transaction_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
let transaction = Transaction {
|
|
id: transaction_id.clone(),
|
|
user_id: user_email.clone(),
|
|
transaction_type: TransactionType::Purchase {
|
|
product_id: "credits".to_string(),
|
|
},
|
|
amount: request.amount,
|
|
currency: Some("USD".to_string()),
|
|
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
|
|
amount_usd: Some(request.amount),
|
|
description: Some(format!("Credits purchase via {}", request.payment_method)),
|
|
reference_id: Some(transaction_id.clone()),
|
|
metadata: None,
|
|
timestamp: Utc::now(),
|
|
status: TransactionStatus::Completed,
|
|
};
|
|
|
|
// Load and update persistent data (locked)
|
|
let mut persistent_data = crate::services::user_persistence::UserPersistence::load_user_data_locked(&user_email, Some(&req_id)).await
|
|
.unwrap_or_else(|| crate::services::user_persistence::UserPersistentData {
|
|
user_email: user_email.clone(),
|
|
wallet_balance_usd: dec!(0),
|
|
..Default::default()
|
|
});
|
|
|
|
// Add balance to persistent data
|
|
persistent_data.wallet_balance_usd += request.amount;
|
|
|
|
// Add transaction to persistent data
|
|
persistent_data.transactions.push(transaction.clone());
|
|
|
|
// Add user activity to persistent data
|
|
persistent_data.user_activities.insert(0, UserActivity {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
user_email: user_email.clone(),
|
|
activity_type: ActivityType::WalletTransaction,
|
|
description: format!("Purchased ${} credits via {}", request.amount, request.payment_method),
|
|
timestamp: Utc::now(),
|
|
metadata: None,
|
|
category: "Wallet".to_string(),
|
|
importance: ActivityImportance::Medium,
|
|
ip_address: None,
|
|
user_agent: None,
|
|
session_id: None,
|
|
});
|
|
|
|
// Keep only last 50 activities
|
|
if persistent_data.user_activities.len() > 50 {
|
|
persistent_data.user_activities.truncate(50);
|
|
}
|
|
|
|
// Update user preferences to remember the last payment method
|
|
if let Some(ref mut preferences) = persistent_data.user_preferences {
|
|
preferences.last_payment_method = Some(request.payment_method.clone());
|
|
} else {
|
|
// Create default preferences with the payment method
|
|
persistent_data.user_preferences = Some(crate::models::user::UserPreferences {
|
|
preferred_currency: "USD".to_string(),
|
|
preferred_language: "en".to_string(),
|
|
timezone: "UTC".to_string(),
|
|
dashboard_layout: "default".to_string(),
|
|
notification_settings: Some(crate::models::user::NotificationSettings {
|
|
email_enabled: true,
|
|
push_enabled: true,
|
|
sms_enabled: false,
|
|
slack_webhook: None,
|
|
discord_webhook: None,
|
|
enabled: true,
|
|
push: true,
|
|
node_offline_alerts: true,
|
|
maintenance_reminders: true,
|
|
earnings_reports: true,
|
|
}),
|
|
privacy_settings: Some(crate::models::user::PrivacySettings {
|
|
profile_public: false,
|
|
email_public: false,
|
|
activity_public: false,
|
|
stats_public: false,
|
|
profile_visibility: "private".to_string(),
|
|
marketing_emails: false,
|
|
data_sharing: false,
|
|
activity_tracking: true,
|
|
}),
|
|
theme: "light".to_string(),
|
|
last_payment_method: Some(request.payment_method.clone()),
|
|
currency_display: "symbol".to_string(),
|
|
data_sharing: false,
|
|
email_notifications: true,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
// Save persistent data (locked)
|
|
if let Err(e) = crate::services::user_persistence::UserPersistence::save_user_data_locked(&persistent_data, Some(&req_id)).await {
|
|
return ResponseBuilder::internal_error()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "Failed to create transaction".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build();
|
|
}
|
|
|
|
let new_balance = persistent_data.wallet_balance_usd;
|
|
|
|
ResponseBuilder::ok()
|
|
.json(WalletResponse {
|
|
success: true,
|
|
message: "Funds added successfully".to_string(),
|
|
transaction_id: Some(transaction_id),
|
|
new_balance: Some(new_balance),
|
|
})
|
|
.build()
|
|
}
|
|
|
|
/// Sell Credits (USD-based)
|
|
pub async fn sell_credits(
|
|
request: web::Json<SellCreditsRequest>,
|
|
session: Session,
|
|
) -> Result<impl Responder> {
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
// Get user email from session
|
|
let user_email = match session.get::<String>("user_email")? {
|
|
Some(email) => email,
|
|
None => return ResponseBuilder::unauthorized()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "User not authenticated".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build(),
|
|
};
|
|
|
|
// Load persistent data (locked)
|
|
let mut persistent_data = match crate::services::user_persistence::UserPersistence::load_user_data_locked(&user_email, Some(&req_id)).await {
|
|
Some(data) => data,
|
|
None => return ResponseBuilder::unauthorized()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "User data not found".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build(),
|
|
};
|
|
|
|
// Validate amount
|
|
if request.amount <= dec!(0) {
|
|
return ResponseBuilder::bad_request()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "Amount must be greater than 0".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build();
|
|
}
|
|
|
|
// Check if user has sufficient balance (unified insufficient funds contract)
|
|
if persistent_data.wallet_balance_usd < request.amount {
|
|
let required = request.amount;
|
|
let available = persistent_data.wallet_balance_usd;
|
|
let deficit = required - available;
|
|
return crate::utils::response_builder::ResponseBuilder::payment_required()
|
|
.error_envelope(
|
|
"INSUFFICIENT_FUNDS",
|
|
"Insufficient balance",
|
|
serde_json::json!({
|
|
"currency": "USD",
|
|
"wallet_balance_usd": available,
|
|
"required_usd": required,
|
|
"deficit_usd": deficit
|
|
})
|
|
)
|
|
.build();
|
|
}
|
|
|
|
// Calculate exchange amount (Credits are 1:1 with USD)
|
|
let exchange_amount = request.amount;
|
|
|
|
// Create transaction
|
|
let transaction_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
let transaction = Transaction {
|
|
id: transaction_id.clone(),
|
|
user_id: user_email.clone(),
|
|
transaction_type: TransactionType::CreditsSale {
|
|
amount_usd: request.amount,
|
|
rate: if exchange_amount != rust_decimal::Decimal::ZERO {
|
|
request.amount / exchange_amount
|
|
} else {
|
|
rust_decimal::Decimal::ONE
|
|
},
|
|
},
|
|
amount: -request.amount,
|
|
currency: Some("USD".to_string()),
|
|
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
|
|
amount_usd: Some(request.amount),
|
|
description: Some(format!("Credits sale to {} via {}", request.currency, request.payout_method)),
|
|
reference_id: Some(format!("sale-{}", uuid::Uuid::new_v4())),
|
|
metadata: None,
|
|
timestamp: Utc::now(),
|
|
status: TransactionStatus::Completed,
|
|
};
|
|
|
|
// Update persistent data
|
|
persistent_data.wallet_balance_usd -= request.amount;
|
|
persistent_data.transactions.push(transaction.clone());
|
|
|
|
// Add user activity
|
|
persistent_data.user_activities.insert(0, UserActivity {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
user_email: user_email.clone(),
|
|
activity_type: ActivityType::WalletTransaction,
|
|
description: format!("Sold ${} credits for {} {} via {}", request.amount, exchange_amount, request.currency, request.payout_method),
|
|
timestamp: Utc::now(),
|
|
metadata: None,
|
|
category: "Wallet".to_string(),
|
|
importance: ActivityImportance::Medium,
|
|
ip_address: None,
|
|
user_agent: None,
|
|
session_id: None,
|
|
});
|
|
|
|
// Keep only last 50 activities
|
|
if persistent_data.user_activities.len() > 50 {
|
|
persistent_data.user_activities.truncate(50);
|
|
}
|
|
|
|
// Save persistent data (locked)
|
|
if let Err(e) = crate::services::user_persistence::UserPersistence::save_user_data_locked(&persistent_data, Some(&req_id)).await {
|
|
return ResponseBuilder::internal_error()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "Failed to create transaction".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build();
|
|
}
|
|
|
|
let new_balance = persistent_data.wallet_balance_usd;
|
|
|
|
ResponseBuilder::ok()
|
|
.json(WalletResponse {
|
|
success: true,
|
|
message: format!("Successfully sold ${} credits for {:.2} {}", request.amount, exchange_amount, request.currency),
|
|
transaction_id: Some(transaction_id),
|
|
new_balance: Some(new_balance),
|
|
})
|
|
.build()
|
|
}
|
|
|
|
/// Transfer Credits (USD-based)
|
|
pub async fn transfer_credits(
|
|
request: web::Json<TransferCreditsRequest>,
|
|
session: Session,
|
|
) -> Result<impl Responder> {
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
// Get user email from session
|
|
let user_email = match session.get::<String>("user_email")? {
|
|
Some(email) => email,
|
|
None => return ResponseBuilder::unauthorized()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "User not authenticated".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build(),
|
|
};
|
|
|
|
// Load persistent data (locked)
|
|
let mut persistent_data = match crate::services::user_persistence::UserPersistence::load_user_data_locked(&user_email, Some(&req_id)).await {
|
|
Some(data) => data,
|
|
None => return ResponseBuilder::unauthorized()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "User data not found".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build(),
|
|
};
|
|
|
|
// Validate amount
|
|
if request.amount <= dec!(0) {
|
|
return ResponseBuilder::bad_request()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "Amount must be greater than 0".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build();
|
|
}
|
|
|
|
// Check if user has sufficient balance (unified insufficient funds contract)
|
|
if persistent_data.wallet_balance_usd < request.amount {
|
|
let required = request.amount;
|
|
let available = persistent_data.wallet_balance_usd;
|
|
let deficit = required - available;
|
|
return crate::utils::response_builder::ResponseBuilder::payment_required()
|
|
.error_envelope(
|
|
"INSUFFICIENT_FUNDS",
|
|
"Insufficient balance",
|
|
serde_json::json!({
|
|
"currency": "USD",
|
|
"wallet_balance_usd": available,
|
|
"required_usd": required,
|
|
"deficit_usd": deficit
|
|
})
|
|
)
|
|
.build();
|
|
}
|
|
|
|
// Check if transferring to self (structured 400)
|
|
if user_email == request.to_user {
|
|
return crate::utils::response_builder::ResponseBuilder::bad_request()
|
|
.error_envelope(
|
|
"INVALID_RECIPIENT_SELF",
|
|
"Cannot transfer to self",
|
|
serde_json::json!({ "to_user": request.to_user })
|
|
)
|
|
.build();
|
|
}
|
|
|
|
// Validate recipient exists before proceeding (no auto-account creation)
|
|
if crate::services::user_persistence::UserPersistence::load_user_data_locked(&request.to_user, Some(&req_id)).await.is_none() {
|
|
return crate::utils::response_builder::ResponseBuilder::not_found()
|
|
.error_envelope(
|
|
"RECIPIENT_NOT_FOUND",
|
|
"Recipient user not found",
|
|
serde_json::json!({ "to_user": request.to_user })
|
|
)
|
|
.build();
|
|
}
|
|
|
|
// Create transaction
|
|
let transaction_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
let transaction = Transaction {
|
|
id: transaction_id.clone(),
|
|
user_id: user_email.clone(),
|
|
transaction_type: TransactionType::CreditsTransfer {
|
|
to_user: request.to_user.clone(),
|
|
note: request.note.clone(),
|
|
},
|
|
amount: request.amount,
|
|
currency: Some("USD".to_string()),
|
|
exchange_rate_usd: Some(rust_decimal::Decimal::ONE),
|
|
amount_usd: Some(request.amount),
|
|
description: Some(format!("Credits transfer to {}: {}", request.to_user, request.note.as_deref().unwrap_or("No note"))),
|
|
reference_id: Some(format!("transfer-{}", uuid::Uuid::new_v4())),
|
|
metadata: None,
|
|
timestamp: Utc::now(),
|
|
status: TransactionStatus::Completed,
|
|
};
|
|
|
|
// Update sender's persistent data
|
|
persistent_data.wallet_balance_usd -= request.amount;
|
|
persistent_data.transactions.push(transaction.clone());
|
|
|
|
// Add user activity
|
|
let note_text = request.note.as_ref()
|
|
.map(|n| format!(" ({})", n))
|
|
.unwrap_or_default();
|
|
|
|
persistent_data.user_activities.insert(0, UserActivity {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
user_email: user_email.clone(),
|
|
activity_type: ActivityType::WalletTransaction,
|
|
description: format!("Transferred ${} credits to {}{}", request.amount, request.to_user, note_text),
|
|
timestamp: Utc::now(),
|
|
metadata: None,
|
|
category: "Wallet".to_string(),
|
|
importance: ActivityImportance::Medium,
|
|
ip_address: None,
|
|
user_agent: None,
|
|
session_id: None,
|
|
});
|
|
|
|
// Keep only last 50 activities
|
|
if persistent_data.user_activities.len() > 50 {
|
|
persistent_data.user_activities.truncate(50);
|
|
}
|
|
|
|
// Save sender's persistent data (locked)
|
|
if let Err(e) = crate::services::user_persistence::UserPersistence::save_user_data_locked(&persistent_data, Some(&req_id)).await {
|
|
return ResponseBuilder::internal_error()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "Failed to create transaction".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build();
|
|
}
|
|
|
|
let new_balance = persistent_data.wallet_balance_usd;
|
|
|
|
// Credit the recipient
|
|
if let Err(e) = Self::credit_recipient(&request.to_user, &user_email, request.amount, &transaction_id, Some(&req_id)).await {
|
|
return ResponseBuilder::internal_error()
|
|
.json(WalletResponse {
|
|
success: false,
|
|
message: "Failed to save recipient data".to_string(),
|
|
transaction_id: None,
|
|
new_balance: None,
|
|
})
|
|
.build();
|
|
}
|
|
|
|
ResponseBuilder::ok()
|
|
.json(WalletResponse {
|
|
success: true,
|
|
message: format!("Successfully transferred ${} to {}", request.amount, request.to_user),
|
|
transaction_id: Some(transaction_id),
|
|
new_balance: Some(new_balance),
|
|
})
|
|
.build()
|
|
}
|
|
|
|
/// Get wallet balance
|
|
pub async fn get_balance(session: Session) -> Result<impl Responder> {
|
|
// Get user email from session
|
|
let user_email = match session.get::<String>("user_email")? {
|
|
Some(email) => email,
|
|
None => {
|
|
return ResponseBuilder::unauthorized()
|
|
.json(serde_json::json!({
|
|
"error": "User not authenticated"
|
|
}))
|
|
.build();
|
|
}
|
|
};
|
|
|
|
// Load persistent data (locked)
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
let persistent_data = match crate::services::user_persistence::UserPersistence::load_user_data_locked(&user_email, Some(&req_id)).await {
|
|
Some(data) => data,
|
|
None => {
|
|
return ResponseBuilder::unauthorized()
|
|
.json(serde_json::json!({
|
|
"error": "User data not found"
|
|
}))
|
|
.build();
|
|
}
|
|
};
|
|
|
|
let balance = persistent_data.wallet_balance_usd;
|
|
|
|
ResponseBuilder::ok()
|
|
.json(serde_json::json!({
|
|
"balance": balance,
|
|
"currency": "USD"
|
|
}))
|
|
.build()
|
|
}
|
|
|
|
/// Get wallet information including balance and recent transactions
|
|
pub async fn get_wallet_info(session: Session) -> Result<impl Responder> {
|
|
// Get user email from session
|
|
let user_email = match session.get::<String>("user_email")? {
|
|
Some(email) => email,
|
|
None => {
|
|
return ResponseBuilder::unauthorized()
|
|
.json(serde_json::json!({
|
|
"error": "User not authenticated"
|
|
}))
|
|
.build();
|
|
}
|
|
};
|
|
|
|
// Load persistent data (locked)
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
let persistent_data = match crate::services::user_persistence::UserPersistence::load_user_data_locked(&user_email, Some(&req_id)).await {
|
|
Some(data) => data,
|
|
None => {
|
|
return ResponseBuilder::unauthorized()
|
|
.json(serde_json::json!({
|
|
"error": "User data not found"
|
|
}))
|
|
.build();
|
|
}
|
|
};
|
|
|
|
let balance = persistent_data.wallet_balance_usd;
|
|
let mut transactions = persistent_data.transactions.clone();
|
|
|
|
// Sort transactions by timestamp (newest first) and take last 10
|
|
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
|
transactions.truncate(10);
|
|
|
|
let wallet_info = WalletInfo {
|
|
balance,
|
|
currency: "USD".to_string(),
|
|
recent_transactions: transactions,
|
|
};
|
|
|
|
ResponseBuilder::ok().json(wallet_info).build()
|
|
}
|
|
|
|
/// Get transaction history
|
|
pub async fn get_transactions(session: Session) -> Result<impl Responder> {
|
|
// Get user email from session
|
|
let user_email = match session.get::<String>("user_email")? {
|
|
Some(email) => email,
|
|
None => {
|
|
return ResponseBuilder::unauthorized()
|
|
.json(serde_json::json!({
|
|
"error": "User not authenticated"
|
|
}))
|
|
.build();
|
|
}
|
|
};
|
|
|
|
// Load persistent data (locked)
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
let persistent_data = match crate::services::user_persistence::UserPersistence::load_user_data_locked(&user_email, Some(&req_id)).await {
|
|
Some(data) => data,
|
|
None => {
|
|
return ResponseBuilder::unauthorized()
|
|
.json(serde_json::json!({
|
|
"error": "User data not found"
|
|
}))
|
|
.build();
|
|
}
|
|
};
|
|
|
|
let mut transactions = persistent_data.transactions.clone();
|
|
|
|
// Sort transactions by timestamp (newest first)
|
|
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
|
|
|
// Format transactions for consistent API response
|
|
let formatted_transactions: Vec<serde_json::Value> = transactions.iter()
|
|
.map(|t| {
|
|
serde_json::json!({
|
|
"id": t.id,
|
|
"user_id": t.user_id,
|
|
"transaction_type": t.transaction_type,
|
|
"amount": t.amount,
|
|
"status": t.status,
|
|
"formatted_timestamp": t.timestamp.format("%Y-%m-%d %H:%M").to_string(),
|
|
"timestamp": t.timestamp.to_rfc3339()
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
ResponseBuilder::ok().json(formatted_transactions).build()
|
|
}
|
|
|
|
/// Execute instant purchase (buy-now functionality)
|
|
pub async fn instant_purchase(
|
|
request: web::Json<crate::services::instant_purchase::InstantPurchaseRequest>,
|
|
session: Session,
|
|
) -> Result<impl Responder> {
|
|
let instant_purchase_service = crate::services::instant_purchase::InstantPurchaseService::builder()
|
|
.build()
|
|
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
|
|
|
|
match instant_purchase_service.execute_instant_purchase(&session, request.into_inner()).await {
|
|
Ok(response) => ResponseBuilder::ok().json(response).build(),
|
|
Err(error) => ResponseBuilder::bad_request().json(serde_json::json!({
|
|
"success": false,
|
|
"message": error
|
|
})).build(),
|
|
}
|
|
}
|
|
|
|
/// Execute quick wallet top-up
|
|
pub async fn quick_topup(
|
|
request: web::Json<crate::services::instant_purchase::QuickTopupRequest>,
|
|
session: Session,
|
|
) -> Result<impl Responder> {
|
|
let instant_purchase_service = crate::services::instant_purchase::InstantPurchaseService::builder()
|
|
.build()
|
|
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
|
|
|
|
match instant_purchase_service.execute_quick_topup(&session, request.into_inner()).await {
|
|
Ok(response) => ResponseBuilder::ok().json(response).build(),
|
|
Err(error) => ResponseBuilder::bad_request().json(serde_json::json!({
|
|
"success": false,
|
|
"message": error
|
|
})).build(),
|
|
}
|
|
}
|
|
|
|
/// Get navbar dropdown data
|
|
pub async fn get_navbar_data(session: Session) -> Result<impl Responder> {
|
|
let navbar_service = crate::services::navbar::NavbarService::builder()
|
|
.build()
|
|
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
|
|
|
|
// Check if user is authenticated
|
|
if session.get::<String>("user_email").unwrap_or(None).is_some() {
|
|
match navbar_service.get_dropdown_data(&session) {
|
|
Ok(data) => {
|
|
// The navbar service already includes currency information
|
|
ResponseBuilder::ok().json(data).build()
|
|
},
|
|
Err(error) => ResponseBuilder::bad_request().json(serde_json::json!({
|
|
"error": error
|
|
})).build(),
|
|
}
|
|
} else {
|
|
// Return guest data
|
|
let guest_data = crate::services::navbar::NavbarService::get_guest_data();
|
|
ResponseBuilder::ok().json(guest_data).build()
|
|
}
|
|
}
|
|
|
|
/// Check if user can afford a purchase
|
|
pub async fn check_affordability(
|
|
query: web::Query<std::collections::HashMap<String, String>>,
|
|
session: Session,
|
|
) -> Result<impl Responder> {
|
|
let amount_str = query.get("amount").ok_or_else(|| {
|
|
actix_web::error::ErrorBadRequest("Missing amount parameter")
|
|
})?;
|
|
let amount = amount_str.parse::<rust_decimal::Decimal>()
|
|
.map_err(|_| actix_web::error::ErrorBadRequest("Invalid amount format"))?;
|
|
|
|
let instant_purchase_service = crate::services::instant_purchase::InstantPurchaseService::builder()
|
|
.build()
|
|
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
|
|
|
|
match instant_purchase_service.check_affordability(&session, amount) {
|
|
Ok(can_afford) => {
|
|
if can_afford {
|
|
ResponseBuilder::ok().json(serde_json::json!({
|
|
"can_afford": true
|
|
})).build()
|
|
} else {
|
|
// Get shortfall information
|
|
match instant_purchase_service.get_balance_shortfall(&session, amount) {
|
|
Ok(Some(shortfall_info)) => ResponseBuilder::ok().json(serde_json::json!({
|
|
"can_afford": false,
|
|
"shortfall_info": shortfall_info
|
|
})).build(),
|
|
Ok(None) => ResponseBuilder::ok().json(serde_json::json!({
|
|
"can_afford": true
|
|
})).build(),
|
|
Err(error) => ResponseBuilder::bad_request().json(serde_json::json!({
|
|
"error": error
|
|
})).build(),
|
|
}
|
|
}
|
|
},
|
|
Err(error) => ResponseBuilder::bad_request().json(serde_json::json!({
|
|
"error": error
|
|
})).build(),
|
|
}
|
|
}
|
|
|
|
/// Get quick top-up amounts for user's preferred currency
|
|
pub async fn get_quick_topup_amounts(session: Session) -> Result<impl Responder> {
|
|
let navbar_service = crate::services::navbar::NavbarService::builder()
|
|
.build()
|
|
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
|
|
|
|
match navbar_service.get_quick_topup_amounts(&session) {
|
|
Ok(amounts) => ResponseBuilder::ok().json(serde_json::json!({
|
|
"amounts": amounts
|
|
})).build(),
|
|
Err(error) => ResponseBuilder::bad_request().json(serde_json::json!({
|
|
"error": error
|
|
})).build(),
|
|
}
|
|
}
|
|
|
|
/// Configure auto top-up settings
|
|
pub async fn configure_auto_topup(
|
|
request: actix_web::web::Json<ConfigureAutoTopUpRequest>,
|
|
session: Session,
|
|
) -> Result<impl Responder> {
|
|
let auto_topup_service = crate::services::auto_topup::AutoTopUpService::builder()
|
|
.build()
|
|
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
|
|
|
|
// Convert the DTO to AutoTopUpSettings with proper DateTime fields
|
|
let settings = crate::services::user_persistence::AutoTopUpSettings {
|
|
enabled: request.enabled,
|
|
threshold_amount_usd: request.threshold_amount,
|
|
topup_amount_usd: request.topup_amount,
|
|
payment_method_id: request.payment_method_id.clone(),
|
|
daily_limit_usd: request.daily_limit,
|
|
monthly_limit_usd: request.monthly_limit,
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
};
|
|
|
|
match auto_topup_service.configure_auto_topup(&session, settings) {
|
|
Ok(()) => ResponseBuilder::ok().json(serde_json::json!({
|
|
"success": true,
|
|
"message": "Auto top-up configured successfully"
|
|
})).build(),
|
|
Err(e) => ResponseBuilder::bad_request().json(serde_json::json!({
|
|
"success": false,
|
|
"message": e
|
|
})).build(),
|
|
}
|
|
}
|
|
|
|
/// Get auto top-up status
|
|
pub async fn get_auto_topup_status(session: Session) -> Result<impl Responder> {
|
|
let user_email = session.get::<String>("user_email")
|
|
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?
|
|
.ok_or_else(|| actix_web::error::ErrorUnauthorized("User not authenticated"))?;
|
|
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
let persistent_data = crate::services::user_persistence::UserPersistence::load_user_data_locked(&user_email, Some(&req_id)).await
|
|
.unwrap_or_default();
|
|
|
|
let auto_topup_enabled = persistent_data.auto_topup_settings
|
|
.as_ref()
|
|
.map(|s| s.enabled)
|
|
.unwrap_or(false);
|
|
|
|
ResponseBuilder::ok().json(serde_json::json!({
|
|
"enabled": auto_topup_enabled,
|
|
"settings": persistent_data.auto_topup_settings
|
|
})).build()
|
|
}
|
|
|
|
/// Trigger auto top-up
|
|
pub async fn trigger_auto_topup(
|
|
request: actix_web::web::Json<serde_json::Value>,
|
|
session: Session,
|
|
) -> Result<impl Responder> {
|
|
let auto_topup_service = crate::services::auto_topup::AutoTopUpService::builder()
|
|
.build()
|
|
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
|
|
|
|
let required_amount = request.get("required_amount")
|
|
.and_then(|v| v.as_f64())
|
|
.map(|f| rust_decimal::Decimal::from_f64_retain(f).unwrap_or_default())
|
|
.unwrap_or_default();
|
|
|
|
match auto_topup_service.check_and_trigger_topup(&session, required_amount).await {
|
|
Ok(success) => ResponseBuilder::ok().json(serde_json::json!({
|
|
"success": success,
|
|
"message": if success { "Auto top-up completed" } else { "Auto top-up failed" }
|
|
})).build(),
|
|
Err(e) => ResponseBuilder::bad_request().json(serde_json::json!({
|
|
"success": false,
|
|
"message": e
|
|
})).build(),
|
|
}
|
|
}
|
|
|
|
/// Get the last used payment method for pre-filling buy credits form
|
|
pub async fn get_last_payment_method(
|
|
session: Session,
|
|
) -> Result<impl Responder> {
|
|
// Check authentication
|
|
if let Err(response) = Self::check_authentication(&session) {
|
|
return Ok(response);
|
|
}
|
|
|
|
let user_email = session.get::<String>("user_email")?.unwrap();
|
|
|
|
// Load user persistent data (locked)
|
|
let req_id = uuid::Uuid::new_v4().to_string();
|
|
let persistent_data = crate::services::user_persistence::UserPersistence::load_user_data_locked(&user_email, Some(&req_id)).await;
|
|
|
|
let last_payment_method = persistent_data
|
|
.and_then(|data| data.user_preferences)
|
|
.and_then(|prefs| prefs.last_payment_method);
|
|
|
|
ResponseBuilder::ok().json(serde_json::json!({
|
|
"success": true,
|
|
"last_payment_method": last_payment_method
|
|
})).build()
|
|
}
|
|
} |