use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use std::sync::Arc;
use uuid::Uuid;
use crate::callback::AuthCallback;
use crate::errors::AppError;
use crate::handlers::admin::validate_system_admin;
use crate::models::{
CaptureHoldResponse, CreateHoldRequest, CreateHoldResponse, ReleaseHoldResponse,
SpendCreditsRequest, SpendCreditsResponse,
};
use crate::services::{CreditService, EmailService};
use crate::utils::{validate_currency, validate_metadata_no_secrets, validate_reference_type};
use crate::AppState;
pub async fn spend_credits<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(user_id): Path<Uuid>,
Json(request): Json<SpendCreditsRequest>,
) -> Result<Json<SpendCreditsResponse>, AppError> {
let admin_id = validate_system_admin(&state, &headers).await?;
validate_metadata_no_secrets(request.metadata.as_ref())?;
validate_reference_type(&request.reference_type)?;
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
.spend(
user_id,
request.amount_lamports,
&request.currency,
request.idempotency_key,
&request.reference_type,
request.reference_id,
request.metadata,
)
.await?;
tracing::info!(
admin_id = %admin_id,
user_id = %user_id,
amount_lamports = result.amount_lamports,
currency = %request.currency,
reference_type = %request.reference_type,
reference_id = %request.reference_id,
transaction_id = %result.transaction_id,
"Credit spend operation"
);
Ok(Json(SpendCreditsResponse::from_result(
result,
&request.currency,
)))
}
pub async fn create_hold<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(user_id): Path<Uuid>,
Json(request): Json<CreateHoldRequest>,
) -> Result<Json<CreateHoldResponse>, AppError> {
let admin_id = validate_system_admin(&state, &headers).await?;
validate_metadata_no_secrets(request.metadata.as_ref())?;
if let Some(ref rt) = request.reference_type {
validate_reference_type(rt)?;
}
validate_currency(&request.currency)?;
let _target_user = state
.user_repo
.find_by_id(user_id)
.await?
.ok_or(AppError::NotFound("User not found".into()))?;
if request.ttl_minutes < 1 || request.ttl_minutes > 60 {
return Err(AppError::Validation(
"ttl_minutes must be between 1 and 60".into(),
));
}
let ttl_minutes = request.ttl_minutes;
let credit_service =
CreditService::new(state.credit_repo.clone(), state.credit_hold_repo.clone());
let result = credit_service
.hold(
user_id,
request.amount_lamports,
&request.currency,
request.idempotency_key.clone(),
Some(ttl_minutes),
request.reference_type.as_deref(),
request.reference_id,
request.metadata,
)
.await?;
tracing::info!(
admin_id = %admin_id,
user_id = %user_id,
hold_id = %result.hold_id,
amount_lamports = result.amount_lamports,
currency = %request.currency,
is_new = result.is_new,
expires_at = %result.expires_at,
"Credit hold created"
);
Ok(Json(CreateHoldResponse::from_result(
result,
&request.currency,
)))
}
pub async fn capture_hold<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(hold_id): Path<Uuid>,
) -> Result<Json<CaptureHoldResponse>, AppError> {
let admin_id = validate_system_admin(&state, &headers).await?;
let credit_service =
CreditService::new(state.credit_repo.clone(), state.credit_hold_repo.clone());
let result = credit_service.capture(hold_id).await?;
tracing::info!(
admin_id = %admin_id,
hold_id = %hold_id,
transaction_id = %result.transaction_id,
amount_lamports = result.amount_lamports,
"Credit hold captured"
);
Ok(Json(CaptureHoldResponse::from_result(result)))
}
pub async fn release_hold<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(hold_id): Path<Uuid>,
) -> Result<Json<ReleaseHoldResponse>, AppError> {
let admin_id = validate_system_admin(&state, &headers).await?;
let credit_service =
CreditService::new(state.credit_repo.clone(), state.credit_hold_repo.clone());
credit_service.release(hold_id).await?;
tracing::info!(
admin_id = %admin_id,
hold_id = %hold_id,
"Credit hold released"
);
Ok(Json(ReleaseHoldResponse {
released: true,
message: "Hold released successfully".to_string(),
}))
}
#[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,
},
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(),
))
},
#[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,
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,
};
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,
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,
};
let user = state.user_repo.create(user).await.unwrap();
user.id
}
async fn add_credits_to_user(
state: &Arc<AppState<NoopCallback, LogEmailService>>,
admin_id: Uuid,
user_id: Uuid,
amount: i64,
) {
let credit_service =
CreditService::new(state.credit_repo.clone(), state.credit_hold_repo.clone());
credit_service
.adjust(admin_id, user_id, amount, "SOL", "Test deposit", None, None)
.await
.unwrap();
}
#[tokio::test]
async fn test_spend_credits() {
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;
add_credits_to_user(&state, admin_id, user_id, 1_000_000_000).await;
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", api_key)).unwrap(),
);
let request = SpendCreditsRequest {
amount_lamports: 500_000_000,
currency: "SOL".to_string(),
idempotency_key: "order:test-123".to_string(),
reference_type: "order".to_string(),
reference_id: Uuid::new_v4(),
metadata: None,
};
let result = spend_credits(State(state.clone()), headers, Path(user_id), Json(request))
.await
.unwrap();
assert_eq!(result.0.amount_lamports, 500_000_000);
assert_eq!(result.0.new_balance_lamports, 500_000_000);
}
#[tokio::test]
async fn test_spend_insufficient_balance() {
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 = SpendCreditsRequest {
amount_lamports: 500_000_000,
currency: "SOL".to_string(),
idempotency_key: "order:test-456".to_string(),
reference_type: "order".to_string(),
reference_id: Uuid::new_v4(),
metadata: None,
};
let result =
spend_credits(State(state.clone()), headers, Path(user_id), Json(request)).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_hold_capture_flow() {
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;
add_credits_to_user(&state, admin_id, user_id, 1_000_000_000).await;
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", api_key)).unwrap(),
);
let hold_request = CreateHoldRequest {
amount_lamports: 500_000_000,
currency: "SOL".to_string(),
idempotency_key: "hold:test-789".to_string(),
ttl_minutes: 15,
reference_type: Some("order".to_string()),
reference_id: Some(Uuid::new_v4()),
metadata: None,
};
let hold_result = create_hold(
State(state.clone()),
headers.clone(),
Path(user_id),
Json(hold_request),
)
.await
.unwrap();
assert!(hold_result.0.is_new);
assert_eq!(hold_result.0.amount_lamports, 500_000_000);
let capture_result =
capture_hold(State(state.clone()), headers, Path(hold_result.0.hold_id))
.await
.unwrap();
assert_eq!(capture_result.0.amount_lamports, 500_000_000);
assert_eq!(capture_result.0.new_balance_lamports, 500_000_000);
}
#[tokio::test]
async fn test_hold_release_flow() {
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;
add_credits_to_user(&state, admin_id, user_id, 1_000_000_000).await;
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", api_key)).unwrap(),
);
let hold_request = CreateHoldRequest {
amount_lamports: 500_000_000,
currency: "SOL".to_string(),
idempotency_key: "hold:test-release".to_string(),
ttl_minutes: 15,
reference_type: Some("order".to_string()),
reference_id: Some(Uuid::new_v4()),
metadata: None,
};
let hold_result = create_hold(
State(state.clone()),
headers.clone(),
Path(user_id),
Json(hold_request),
)
.await
.unwrap();
let release_result =
release_hold(State(state.clone()), headers, Path(hold_result.0.hold_id))
.await
.unwrap();
assert!(release_result.0.released);
}
}