use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use serde::Serialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::callback::AuthCallback;
use crate::errors::AppError;
use crate::handlers::admin::users::validate_system_admin;
use crate::models::{AdjustCreditsRequest, AdjustCreditsResponse};
use crate::services::{CreditService, EmailService};
use crate::utils::validate_currency;
use crate::AppState;
#[derive(Debug, Clone, Serialize)]
pub struct CurrencyCreditStatsResponse {
pub total_credited: i64,
pub total_spent: i64,
pub total_positive_adjustments: i64,
pub total_negative_adjustments: i64,
pub current_outstanding: i64,
pub deposit_count: u64,
pub spend_count: u64,
pub adjustment_count: u64,
pub total_credited_display: f64,
pub total_spent_display: f64,
pub current_outstanding_display: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct AdminCreditStatsResponse {
pub sol: CurrencyCreditStatsResponse,
pub usd: CurrencyCreditStatsResponse,
pub total_users_with_balance: u64,
pub total_outstanding_lamports: i64,
pub total_outstanding_sol: f64,
}
pub async fn get_stats<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
) -> Result<Json<AdminCreditStatsResponse>, AppError> {
let _admin_id = validate_system_admin(&state, &headers).await?;
let stats = state.credit_repo.get_stats().await?;
let sol_divisor = 1_000_000_000.0;
let usd_divisor = 1_000_000.0;
Ok(Json(AdminCreditStatsResponse {
sol: CurrencyCreditStatsResponse {
total_credited: stats.sol.total_credited,
total_spent: stats.sol.total_spent,
total_positive_adjustments: stats.sol.total_positive_adjustments,
total_negative_adjustments: stats.sol.total_negative_adjustments,
current_outstanding: stats.sol.current_outstanding,
deposit_count: stats.sol.deposit_count,
spend_count: stats.sol.spend_count,
adjustment_count: stats.sol.adjustment_count,
total_credited_display: stats.sol.total_credited as f64 / sol_divisor,
total_spent_display: stats.sol.total_spent as f64 / sol_divisor,
current_outstanding_display: stats.sol.current_outstanding as f64 / sol_divisor,
},
usd: CurrencyCreditStatsResponse {
total_credited: stats.usd.total_credited,
total_spent: stats.usd.total_spent,
total_positive_adjustments: stats.usd.total_positive_adjustments,
total_negative_adjustments: stats.usd.total_negative_adjustments,
current_outstanding: stats.usd.current_outstanding,
deposit_count: stats.usd.deposit_count,
spend_count: stats.usd.spend_count,
adjustment_count: stats.usd.adjustment_count,
total_credited_display: stats.usd.total_credited as f64 / usd_divisor,
total_spent_display: stats.usd.total_spent as f64 / usd_divisor,
current_outstanding_display: stats.usd.current_outstanding as f64 / usd_divisor,
},
total_users_with_balance: stats.total_users_with_balance,
total_outstanding_lamports: stats.total_outstanding_lamports,
total_outstanding_sol: stats.total_outstanding_lamports as f64 / sol_divisor,
}))
}
pub async fn adjust_credits<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(user_id): Path<Uuid>,
Json(request): Json<AdjustCreditsRequest>,
) -> Result<Json<AdjustCreditsResponse>, AppError> {
let admin_id = validate_system_admin(&state, &headers).await?;
validate_currency(&request.currency)?;
let _target_user = state
.user_repo
.find_by_id(user_id)
.await?
.ok_or(AppError::NotFound("User not found".into()))?;
let credit_service =
CreditService::new(state.credit_repo.clone(), state.credit_hold_repo.clone());
let result = credit_service
.adjust(
admin_id,
user_id,
request.amount_lamports,
&request.currency,
&request.reason,
request.reference_type.as_deref(),
request.reference_id,
)
.await?;
let sol_amount = result.new_balance_lamports as f64 / 1_000_000_000.0;
let display = format!("{:.4} SOL", sol_amount);
tracing::info!(
admin_id = %admin_id,
user_id = %user_id,
amount_lamports = result.amount_lamports,
currency = %request.currency,
reason = %request.reason,
reference_type = ?request.reference_type,
transaction_id = %result.transaction_id,
"Admin credit adjustment"
);
Ok(Json(AdjustCreditsResponse {
transaction_id: result.transaction_id,
new_balance_lamports: result.new_balance_lamports,
amount_lamports: result.amount_lamports,
currency: request.currency,
display,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{header, HeaderValue};
use chrono::Utc;
use std::sync::Arc;
use uuid::Uuid;
use crate::config::{
default_access_expiry, default_audience, default_issuer, default_refresh_expiry,
AppleConfig, CookieConfig, CorsConfig, DatabaseConfig, EmailConfig, GoogleConfig,
JwtConfig, NotificationConfig, PrivacyConfig, RateLimitConfig, ServerConfig, SolanaConfig,
SsoConfig, WalletConfig, WebAuthnConfig, WebhookConfig,
};
use crate::repositories::{generate_api_key, ApiKeyEntity, LoginAttemptConfig, UserEntity};
use crate::services::{
create_wallet_unlock_cache, AppleService, AuditService, CommsService, GoogleService,
JwtService, LogEmailService, MfaAttemptService, PasswordService, SolanaService,
TotpService, WalletSigningService, WebAuthnService,
};
use crate::utils::TokenCipher;
use crate::{AppState, Config, NoopCallback, Storage};
fn base_config() -> Config {
Config {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 3001,
auth_base_path: "/auth".to_string(),
frontend_url: None,
sso_callback_url: None,
bootstrap_admin_email: None,
trust_proxy: false,
},
jwt: JwtConfig {
secret: "s".repeat(32),
rsa_private_key_pem: None,
issuer: default_issuer(),
audience: default_audience(),
access_token_expiry: default_access_expiry(),
refresh_token_expiry: default_refresh_expiry(),
},
email: EmailConfig::default(),
google: GoogleConfig {
enabled: false,
client_id: None,
},
apple: AppleConfig {
enabled: false,
client_id: None,
team_id: None,
..AppleConfig::default()
},
solana: SolanaConfig::default(),
webauthn: WebAuthnConfig::default(),
cors: CorsConfig::default(),
cookie: CookieConfig::default(),
webhook: WebhookConfig::default(),
rate_limit: RateLimitConfig::default(),
database: DatabaseConfig::default(),
notification: NotificationConfig::default(),
sso: SsoConfig::default(),
wallet: WalletConfig::default(),
privacy: PrivacyConfig::default(),
}
}
fn build_state(config: Config) -> Arc<AppState<NoopCallback, LogEmailService>> {
let storage = Storage::in_memory();
let settings_service = std::sync::Arc::new(crate::services::SettingsService::new(
storage.system_settings_repo.clone(),
));
let jwt_service = JwtService::new(&config.jwt);
let password_service = PasswordService::default();
let google_service = GoogleService::new(&config.google);
let apple_service = AppleService::new(&config.apple);
let solana_service = SolanaService::new(&config.solana);
let totp_service = TotpService::new("Cedros");
let webauthn_service = WebAuthnService::new(&config.webauthn, settings_service.clone());
let oidc_service = crate::services::OidcService::new(
"http://localhost:8080/auth/sso/callback".to_string(),
);
let encryption_service =
crate::services::EncryptionService::from_secret(&config.jwt.secret);
let audit_service = AuditService::new(storage.audit_repo.clone(), false);
let step_up_service = crate::services::StepUpService::new(storage.session_repo.clone());
let token_cipher = TokenCipher::new(&config.jwt.secret);
let comms_service = CommsService::new(
storage.outbox_repo.clone(),
"http://localhost:3000".to_string(),
token_cipher,
);
Arc::new(AppState {
config,
callback: Arc::new(NoopCallback),
jwt_service,
password_service,
google_service,
apple_service,
solana_service,
totp_service,
webauthn_service,
oidc_service,
encryption_service,
phantom_email: std::marker::PhantomData::<LogEmailService>,
audit_service,
comms_service,
user_repo: storage.user_repo.clone(),
session_repo: storage.session_repo.clone(),
nonce_repo: storage.nonce_repo.clone(),
verification_repo: storage.verification_repo.clone(),
org_repo: storage.org_repo.clone(),
membership_repo: storage.membership_repo.clone(),
invite_repo: storage.invite_repo.clone(),
audit_repo: storage.audit_repo.clone(),
login_attempt_repo: storage.login_attempt_repo.clone(),
login_attempt_config: LoginAttemptConfig::default(),
totp_repo: storage.totp_repo.clone(),
custom_role_repo: storage.custom_role_repo.clone(),
policy_repo: storage.policy_repo.clone(),
outbox_repo: storage.outbox_repo.clone(),
api_key_repo: storage.api_key_repo.clone(),
wallet_material_repo: storage.wallet_material_repo.clone(),
derived_wallet_repo: storage.derived_wallet_repo.clone(),
wallet_rotation_history_repo: storage.wallet_rotation_history_repo.clone(),
credential_repo: storage.credential_repo.clone(),
webauthn_repo: storage.webauthn_repo.clone(),
deposit_repo: storage.deposit_repo.clone(),
credit_repo: storage.credit_repo.clone(),
credit_hold_repo: storage.credit_hold_repo.clone(),
credit_refund_request_repo: storage.credit_refund_request_repo.clone(),
privacy_note_repo: storage.privacy_note_repo.clone(),
system_settings_repo: storage.system_settings_repo.clone(),
settings_service: settings_service.clone(),
mfa_attempt_service: MfaAttemptService::new(),
step_up_service,
wallet_signing_service: WalletSigningService::new(),
wallet_unlock_cache: create_wallet_unlock_cache(),
treasury_config_repo: storage.treasury_config_repo.clone(),
user_withdrawal_log_repo: storage.user_withdrawal_log_repo.clone(),
privacy_sidecar_client: None,
note_encryption_service: None,
sol_price_service: std::sync::Arc::new(crate::services::SolPriceService::new()),
jupiter_swap_service: None,
deposit_credit_service: {
let settings_service = std::sync::Arc::new(crate::services::SettingsService::new(
storage.system_settings_repo.clone(),
));
let sol_price_service =
std::sync::Arc::new(crate::services::SolPriceService::new());
let fee_service =
std::sync::Arc::new(crate::services::DepositFeeService::new(settings_service));
std::sync::Arc::new(crate::services::DepositCreditService::new(
sol_price_service,
fee_service,
"USDC".to_string(),
))
},
referral_payout_repo: storage.referral_payout_repo.clone(),
referral_code_history_repo: storage.referral_code_history_repo.clone(),
kyc_service: None,
accreditation_service: None,
sanctions_service: std::sync::Arc::new(crate::services::SanctionsService::new(
settings_service.clone(),
)),
token_gating_service: std::sync::Arc::new(crate::services::TokenGatingService::new(
settings_service.clone(),
storage.user_repo.clone(),
storage.wallet_material_repo.clone(),
)),
signup_gating_service: std::sync::Arc::new(crate::services::SignupGatingService::new(
storage.access_code_repo.clone(),
storage.user_repo.clone(),
settings_service.clone(),
)),
#[cfg(feature = "postgres")]
postgres_pool: storage.pg_pool.clone(),
storage,
})
}
async fn setup_admin_with_api_key(
state: &Arc<AppState<NoopCallback, LogEmailService>>,
) -> (Uuid, String) {
let now = Utc::now();
let user = UserEntity {
id: Uuid::new_v4(),
email: Some("admin@example.com".to_string()),
email_verified: true,
password_hash: None,
name: None,
username: None,
picture: None,
wallet_address: None,
google_id: None,
apple_id: None,
stripe_customer_id: None,
auth_methods: vec![],
is_system_admin: true,
created_at: now,
updated_at: now,
last_login_at: None,
welcome_completed_at: None,
referral_code: "TESTCODE".to_string(),
referred_by: None,
payout_wallet_address: None,
kyc_status: "none".to_string(),
kyc_verified_at: None,
kyc_expires_at: None,
accreditation_status: "none".to_string(),
accreditation_verified_at: None,
accreditation_expires_at: None,
};
let user = state.user_repo.create(user).await.unwrap();
let api_key = generate_api_key();
let api_key_entity = ApiKeyEntity::new(user.id, &api_key, "default");
state.api_key_repo.create(api_key_entity).await.unwrap();
(user.id, api_key)
}
async fn setup_regular_user(state: &Arc<AppState<NoopCallback, LogEmailService>>) -> Uuid {
let now = Utc::now();
let user = UserEntity {
id: Uuid::new_v4(),
email: Some("user@example.com".to_string()),
email_verified: true,
password_hash: None,
name: None,
username: None,
picture: None,
wallet_address: None,
google_id: None,
apple_id: None,
stripe_customer_id: None,
auth_methods: vec![],
is_system_admin: false,
created_at: now,
updated_at: now,
last_login_at: None,
welcome_completed_at: None,
referral_code: "TESTCODE".to_string(),
referred_by: None,
payout_wallet_address: None,
kyc_status: "none".to_string(),
kyc_verified_at: None,
kyc_expires_at: None,
accreditation_status: "none".to_string(),
accreditation_verified_at: None,
accreditation_expires_at: None,
};
let user = state.user_repo.create(user).await.unwrap();
user.id
}
#[tokio::test]
async fn test_adjust_credits_add() {
let config = base_config();
let state = build_state(config);
let (_admin_id, api_key) = setup_admin_with_api_key(&state).await;
let user_id = setup_regular_user(&state).await;
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", api_key)).unwrap(),
);
let request = AdjustCreditsRequest {
amount_lamports: 1_000_000_000, currency: "SOL".to_string(),
reason: "Welcome bonus".to_string(),
reference_type: Some("bonus".to_string()),
reference_id: None,
};
let result = adjust_credits(State(state.clone()), headers, Path(user_id), Json(request))
.await
.unwrap();
assert_eq!(result.0.amount_lamports, 1_000_000_000);
assert_eq!(result.0.new_balance_lamports, 1_000_000_000);
assert_eq!(result.0.currency, "SOL");
}
#[tokio::test]
async fn test_adjust_credits_requires_admin() {
let config = base_config();
let state = build_state(config);
let now = Utc::now();
let user = UserEntity {
id: Uuid::new_v4(),
email: Some("nonadmin@example.com".to_string()),
email_verified: true,
password_hash: None,
name: None,
username: None,
picture: None,
wallet_address: None,
google_id: None,
apple_id: None,
stripe_customer_id: None,
auth_methods: vec![],
is_system_admin: false,
created_at: now,
updated_at: now,
last_login_at: None,
welcome_completed_at: None,
referral_code: "TESTCODE".to_string(),
referred_by: None,
payout_wallet_address: None,
kyc_status: "none".to_string(),
kyc_verified_at: None,
kyc_expires_at: None,
accreditation_status: "none".to_string(),
accreditation_verified_at: None,
accreditation_expires_at: None,
};
let user = state.user_repo.create(user).await.unwrap();
let api_key = generate_api_key();
let api_key_entity = ApiKeyEntity::new(user.id, &api_key, "default");
state.api_key_repo.create(api_key_entity).await.unwrap();
let target_user_id = setup_regular_user(&state).await;
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", api_key)).unwrap(),
);
let request = AdjustCreditsRequest {
amount_lamports: 1_000_000_000,
currency: "SOL".to_string(),
reason: "Should fail".to_string(),
reference_type: None,
reference_id: None,
};
let result =
adjust_credits(State(state), headers, Path(target_user_id), Json(request)).await;
assert!(matches!(result, Err(AppError::Forbidden(_))));
}
#[tokio::test]
async fn test_adjust_credits_user_not_found() {
let config = base_config();
let state = build_state(config);
let (_admin_id, api_key) = setup_admin_with_api_key(&state).await;
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", api_key)).unwrap(),
);
let request = AdjustCreditsRequest {
amount_lamports: 1_000_000_000,
currency: "SOL".to_string(),
reason: "Should fail".to_string(),
reference_type: None,
reference_id: None,
};
let result = adjust_credits(
State(state),
headers,
Path(Uuid::new_v4()), Json(request),
)
.await;
assert!(matches!(result, Err(AppError::NotFound(_))));
}
}