From 72ffc1e81156319d01bfdb1b2ead5ea4396a82b1 Mon Sep 17 00:00:00 2001 From: Iyanu Majekodunmi Date: Mon, 1 Jun 2026 19:08:37 +0000 Subject: [PATCH] feat: add wallet and payment HTTP endpoints - Created src/http/wallet.rs with wallet endpoints: - GET /api/wallet - get authenticated user's wallet balances - GET /api/wallet/transactions - paginated transaction history - POST /api/wallet/deposit - initiate a fiat deposit via Paystack/Flutterwave - POST /api/wallet/deposit/verify - verify deposit payment - POST /api/wallet/withdraw - initiate a withdrawal - Added wallet module to http/mod.rs - Registered wallet routes in main.rs --- backend/src/http/mod.rs | 6 +- backend/src/http/wallet.rs | 231 +++++++++++++++++++++++++++++++++++++ backend/src/main.rs | 13 ++- 3 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 backend/src/http/wallet.rs diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs index 787d7fc6..2c038e84 100644 --- a/backend/src/http/mod.rs +++ b/backend/src/http/mod.rs @@ -13,8 +13,4 @@ pub mod social_handler; pub mod staking_handler; pub mod analytics_handler; pub mod tournament_handler; - -// TODO: Add more HTTP modules as implemented: -// pub mod auth; -// pub mod matches; -// pub mod tournaments; +pub mod wallet; diff --git a/backend/src/http/wallet.rs b/backend/src/http/wallet.rs new file mode 100644 index 00000000..5497ceec --- /dev/null +++ b/backend/src/http/wallet.rs @@ -0,0 +1,231 @@ +use actix_web::{web, HttpResponse, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::api_error::ApiError; +use crate::auth::middleware::ClaimsExt; +use crate::models::{ + DepositRequest, TransactionResponse, TransactionStatus, TransactionType, WalletResponse, + WithdrawalRequest, +}; +use crate::service::WalletService; + +#[derive(Deserialize)] +pub struct TransactionHistoryQuery { + pub page: Option, + pub per_page: Option, +} + +#[derive(Deserialize)] +pub struct PaymentVerificationRequest { + pub reference: String, + pub provider: String, +} + +pub async fn get_wallet( + pool: web::Data, + req: actix_web::HttpRequest, +) -> Result { + let user_id = req + .user_id() + .ok_or_else(|| ApiError::unauthorized("User not authenticated"))?; + + let wallet = sqlx::query_as!( + crate::models::Wallet, + r#" + SELECT * FROM wallets + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool.get_ref()) + .await? + .ok_or_else(|| ApiError::not_found("Wallet not found"))?; + + Ok(HttpResponse::Ok().json(WalletResponse::from(wallet))) +} + +pub async fn get_transaction_history( + pool: web::Data, + req: actix_web::HttpRequest, + query: web::Query, +) -> Result { + let user_id = req + .user_id() + .ok_or_else(|| ApiError::unauthorized("User not authenticated"))?; + + let page = query.page.unwrap_or(1).max(1); + let per_page = query.per_page.unwrap_or(20).min(100).max(1); + + let service = WalletService::new(pool.get_ref().clone().into(), None); + let transactions = service + .get_transaction_history(user_id, page, per_page) + .await?; + + let response: Vec = transactions + .into_iter() + .map(|t| TransactionResponse { + id: t.id, + transaction_type: t.transaction_type, + amount: t.amount, + currency: t.currency, + status: t.status, + reference: t.reference, + description: t.description, + stellar_transaction_id: t.stellar_transaction_id, + created_at: t.created_at, + completed_at: t.completed_at, + }) + .collect(); + + Ok(HttpResponse::Ok().json(response)) +} + +pub async fn initiate_deposit( + pool: web::Data, + req: actix_web::HttpRequest, + body: web::Json, +) -> Result { + let user_id = req + .user_id() + .ok_or_else(|| ApiError::unauthorized("User not authenticated"))?; + + let amount = body.amount; + if amount <= rust_decimal::Decimal::ZERO { + return Err(ApiError::bad_request("Amount must be positive")); + } + + let service = WalletService::new(pool.get_ref().clone().into(), None); + let transaction = service + .create_transaction( + user_id, + TransactionType::Deposit, + amount.mantissa(), + body.currency.clone(), + format!("Wallet deposit via {}", body.payment_method), + None, + ) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "transaction_id": transaction.id, + "reference": transaction.reference, + "status": "pending", + "amount": amount, + "currency": body.currency, + "payment_method": body.payment_method, + "message": "Deposit initiated. Complete payment to finalize." + }))) +} + +pub async fn verify_deposit( + pool: web::Data, + req: actix_web::HttpRequest, + body: web::Json, +) -> Result { + let user_id = req + .user_id() + .ok_or_else(|| ApiError::unauthorized("User not authenticated"))?; + + let service = WalletService::new(pool.get_ref().clone().into(), None); + + let transaction = service + .get_transaction_by_reference(&body.reference) + .await?; + + if transaction.status == TransactionStatus::Completed { + return Ok(HttpResponse::Ok().json(serde_json::json!({ + "status": "completed", + "transaction": transaction + }))); + } + + let verified = match body.provider.as_str() { + "paystack" => service.verify_paystack_payment(&body.reference, transaction.amount.mantissa()).await?, + "flutterwave" => service + .verify_flutterwave_payment(&body.reference, transaction.amount.mantissa()) + .await?, + _ => false, + }; + + if verified { + service + .update_transaction_status(transaction.id, TransactionStatus::Completed) + .await?; + } + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "status": if verified { "completed" } else { "failed" }, + "verified": verified + }))) +} + +pub async fn initiate_withdrawal( + pool: web::Data, + req: actix_web::HttpRequest, + body: web::Json, +) -> Result { + let user_id = req + .user_id() + .ok_or_else(|| ApiError::unauthorized("User not authenticated"))?; + + let amount = body.amount; + if amount <= rust_decimal::Decimal::ZERO { + return Err(ApiError::bad_request("Amount must be positive")); + } + + let service = WalletService::new(pool.get_ref().clone().into(), None); + + let wallet = service.get_wallet(user_id).await.map_err(|e| match e { + crate::service::wallet_service::WalletError::InsufficientBalance { required, available } => { + ApiError::bad_request(format!( + "Insufficient balance: required {}, available {}", + required, available + )) + } + _ => ApiError::not_found("Wallet not found"), + })?; + + let available_balance = match body.currency.as_str() { + "NGN" => wallet.balance_ngn.unwrap_or(0), + "XLM" => wallet.balance_xlm.unwrap_or(0), + "ARENAX_TOKEN" => wallet.balance_arenax_tokens.unwrap_or(0), + _ => 0, + }; + + let amount_in_smallest_unit = match body.currency.as_str() { + "NGN" | "ARENAX_TOKEN" => amount.mantissa(), + "XLM" => amount.mantissa() / 1_000_000, + _ => amount.mantissa(), + }; + + if available_balance < amount_in_smallest_unit { + return Err(ApiError::bad_request(format!( + "Insufficient {} balance. Available: {}", + body.currency, available_balance + ))); + } + + let transaction = service + .create_transaction( + user_id, + TransactionType::Withdrawal, + amount.mantissa(), + body.currency.clone(), + format!("Withdrawal to {}", body.destination), + None, + ) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "transaction_id": transaction.id, + "reference": transaction.reference, + "status": "pending", + "amount": amount, + "currency": body.currency, + "destination": body.destination, + "payment_method": body.payment_method, + "message": "Withdrawal initiated. Processing may take a few minutes." + }))) +} \ No newline at end of file diff --git a/backend/src/main.rs b/backend/src/main.rs index 5244821b..88b3647d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -72,7 +72,7 @@ async fn main() -> io::Result<()> { redis_conn.clone(), matchmaking_config, )); - + // Start background matchmaker worker let matchmaker_worker = matchmaker_service.clone(); tokio::spawn(async move { @@ -146,6 +146,15 @@ async fn main() -> io::Result<()> { "/notifications/{id}", web::delete().to(crate::http::notification_handler::delete_notification), ) + // Wallet endpoints + .service( + web::scope("/wallet") + .route("", web::get().to(crate::http::wallet::get_wallet)) + .route("/transactions", web::get().to(crate::http::wallet::get_transaction_history)) + .route("/deposit", web::post().to(crate::http::wallet::initiate_deposit)) + .route("/deposit/verify", web::post().to(crate::http::wallet::verify_deposit)) + .route("/withdraw", web::post().to(crate::http::wallet::initiate_withdrawal)) + ) // Reputation endpoints .route( "/reputation/player/{user_id}", @@ -243,4 +252,4 @@ async fn main() -> io::Result<()> { }); server.await -} +} \ No newline at end of file