using System.ComponentModel.DataAnnotations; using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Permission; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Pass.Wallet; [ApiController] [Route("/api/wallets")] public class WalletController(AppDatabase db, WalletService ws, PaymentService payment, AuthService auth, ICacheService cache) : ControllerBase { [HttpPost] [Authorize] public async Task> CreateWallet() { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); try { var wallet = await ws.CreateWalletAsync(currentUser.Id); return Ok(wallet); } catch (Exception err) { return BadRequest(err.Message); } } [HttpGet] [Authorize] public async Task> GetWallet() { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); var wallet = await ws.GetWalletAsync(currentUser.Id); if (wallet is null) return NotFound("Wallet was not found, please create one first."); return Ok(wallet); } public class WalletStats { public Instant PeriodBegin { get; set; } public Instant PeriodEnd { get; set; } public int TotalTransactions { get; set; } public int TotalOrders { get; set; } public Dictionary IncomeCatgories { get; set; } = null!; public Dictionary OutgoingCategories { get; set; } = null!; public decimal TotalIncome => IncomeCatgories.Values.Sum(); public decimal TotalOutgoing => OutgoingCategories.Values.Sum(); public decimal Sum => TotalIncome - TotalOutgoing; } [HttpGet("stats")] [Authorize] public async Task> GetWalletStats([FromQuery] int period = 30) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); var wallet = await ws.GetWalletAsync(currentUser.Id); if (wallet is null) return NotFound("Wallet was not found, please create one first."); var periodEnd = SystemClock.Instance.GetCurrentInstant(); var periodBegin = periodEnd.Minus(Duration.FromDays(period)); var cacheKey = $"wallet:stats:{currentUser.Id}:{period}"; var cached = await cache.GetAsync(cacheKey); if (cached != null) { return Ok(cached); } var transactions = await db.PaymentTransactions .Where(t => (t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id) && t.CreatedAt >= periodBegin && t.CreatedAt <= periodEnd) .ToListAsync(); var orders = await db.PaymentOrders .Where(o => o.PayeeWalletId == wallet.Id && o.CreatedAt >= periodBegin && o.CreatedAt <= periodEnd) .ToListAsync(); var incomeCategories = transactions .Where(t => t.PayeeWalletId == wallet.Id) .GroupBy(t => t.Type.ToString()) .ToDictionary(g => g.Key, g => g.Sum(t => t.Amount)); var outgoingCategories = transactions .Where(t => t.PayerWalletId == wallet.Id) .GroupBy(t => t.Type.ToString()) .ToDictionary(g => g.Key, g => g.Sum(t => t.Amount)); var stats = new WalletStats { PeriodBegin = periodBegin, PeriodEnd = periodEnd, TotalTransactions = transactions.Count, TotalOrders = orders.Count, IncomeCatgories = incomeCategories, OutgoingCategories = outgoingCategories }; await cache.SetAsync(cacheKey, stats, TimeSpan.FromHours(1)); return Ok(stats); } [HttpGet("transactions")] [Authorize] public async Task>> GetTransactions( [FromQuery] int offset = 0, [FromQuery] int take = 20 ) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync(); if (accountWallet is null) return NotFound(); var query = db.PaymentTransactions .Where(t => t.PayeeWalletId == accountWallet.Id || t.PayerWalletId == accountWallet.Id) .OrderByDescending(t => t.CreatedAt) .AsQueryable(); var transactionCount = await query.CountAsync(); Response.Headers["X-Total"] = transactionCount.ToString(); var transactions = await query .Skip(offset) .Take(take) .Include(t => t.PayerWallet) .ThenInclude(w => w.Account) .ThenInclude(w => w.Profile) .Include(t => t.PayeeWallet) .ThenInclude(w => w.Account) .ThenInclude(w => w.Profile) .ToListAsync(); return Ok(transactions); } [HttpGet("orders")] [Authorize] public async Task>> GetOrders( [FromQuery] int offset = 0, [FromQuery] int take = 20 ) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync(); if (accountWallet is null) return NotFound(); var query = db.PaymentOrders.AsQueryable() .Include(o => o.Transaction) .Where(o => o.Transaction != null && (o.Transaction.PayeeWalletId == accountWallet.Id || o.Transaction.PayerWalletId == accountWallet.Id)) .AsQueryable(); var orderCount = await query.CountAsync(); Response.Headers["X-Total"] = orderCount.ToString(); var orders = await query .Skip(offset) .Take(take) .OrderByDescending(t => t.CreatedAt) .ToListAsync(); return Ok(orders); } public class WalletBalanceRequest { public string? Remark { get; set; } [Required] public decimal Amount { get; set; } [Required] public string Currency { get; set; } = null!; [Required] public Guid AccountId { get; set; } } public class WalletTransferRequest { public string? Remark { get; set; } [Required] public decimal Amount { get; set; } [Required] public string Currency { get; set; } = null!; [Required] public Guid PayeeAccountId { get; set; } } [HttpPost("balance")] [Authorize] [RequiredPermission("maintenance", "wallets.balance.modify")] public async Task> ModifyWalletBalance([FromBody] WalletBalanceRequest request) { var wallet = await ws.GetWalletAsync(request.AccountId); if (wallet is null) return NotFound("Wallet was not found."); var transaction = request.Amount >= 0 ? await payment.CreateTransactionAsync( payerWalletId: null, payeeWalletId: wallet.Id, currency: request.Currency, amount: request.Amount, remarks: request.Remark ) : await payment.CreateTransactionAsync( payerWalletId: wallet.Id, payeeWalletId: null, currency: request.Currency, amount: request.Amount, remarks: request.Remark ); return Ok(transaction); } [HttpPost("transfer")] [Authorize] public async Task> Transfer([FromBody] WalletTransferRequest request) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); var payerWallet = await ws.GetWalletAsync(currentUser.Id); if (payerWallet is null) return NotFound("Your wallet was not found, please create one first."); var payeeWallet = await ws.GetWalletAsync(request.PayeeAccountId); if (payeeWallet is null) return NotFound("Payee wallet was not found."); if (currentUser.Id == request.PayeeAccountId) return BadRequest("Cannot transfer to yourself."); try { var transaction = await payment.CreateTransactionAsync( payerWalletId: payerWallet.Id, payeeWalletId: payeeWallet.Id, currency: request.Currency, amount: request.Amount, remarks: request.Remark ?? $"Transfer from {currentUser.Id} to {request.PayeeAccountId}", type: TransactionType.Transfer ); return Ok(transaction); } catch (Exception err) { return BadRequest(err.Message); } } public class CreateFundRequest { [Required] public List RecipientAccountIds { get; set; } = new(); [Required] public string Currency { get; set; } = null!; [Required] public decimal TotalAmount { get; set; } [Required] public FundSplitType SplitType { get; set; } public string? Message { get; set; } public int? ExpirationHours { get; set; } // Optional: hours until expiration [Required] public string PinCode { get; set; } = null!; // Required PIN for fund creation } [HttpPost("funds")] [Authorize] public async Task> CreateFund([FromBody] CreateFundRequest request) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); // Validate PIN code if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode)) return StatusCode(403, "Invalid PIN Code"); try { Duration? expiration = null; if (request.ExpirationHours.HasValue) { expiration = Duration.FromHours(request.ExpirationHours.Value); } var fund = await payment.CreateFundAsync( creatorAccountId: currentUser.Id, recipientAccountIds: request.RecipientAccountIds, currency: request.Currency, totalAmount: request.TotalAmount, splitType: request.SplitType, message: request.Message, expiration: expiration ); return Ok(fund); } catch (Exception err) { return BadRequest(err.Message); } } [HttpGet("funds")] [Authorize] public async Task>> GetFunds( [FromQuery] int offset = 0, [FromQuery] int take = 20, [FromQuery] FundStatus? status = null ) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); var query = db.WalletFunds .Include(f => f.Recipients) .ThenInclude(r => r.RecipientAccount) .ThenInclude(a => a.Profile) .Include(f => f.CreatorAccount) .ThenInclude(a => a.Profile) .Where(f => f.CreatorAccountId == currentUser.Id || f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id)) .AsQueryable(); if (status.HasValue) { query = query.Where(f => f.Status == status.Value); } var fundCount = await query.CountAsync(); Response.Headers["X-Total"] = fundCount.ToString(); var funds = await query .OrderByDescending(f => f.CreatedAt) .Skip(offset) .Take(take) .ToListAsync(); return Ok(funds); } [HttpGet("funds/{id}")] [Authorize] public async Task> GetFund(Guid id) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); var fund = await db.WalletFunds .Include(f => f.Recipients) .ThenInclude(r => r.RecipientAccount) .ThenInclude(a => a.Profile) .Include(f => f.CreatorAccount) .ThenInclude(a => a.Profile) .FirstOrDefaultAsync(f => f.Id == id); if (fund == null) return NotFound("Fund not found"); // Check if user is creator or recipient var isCreator = fund.CreatorAccountId == currentUser.Id; var isRecipient = fund.Recipients.Any(r => r.RecipientAccountId == currentUser.Id); if (!isCreator && !isRecipient) return Forbid("You don't have permission to view this fund"); return Ok(fund); } [HttpPost("funds/{id}/receive")] [Authorize] public async Task> ReceiveFund(Guid id) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); try { var transaction = await payment.ReceiveFundAsync( recipientAccountId: currentUser.Id, fundId: id ); return Ok(transaction); } catch (Exception err) { return BadRequest(err.Message); } } [HttpGet("overview")] [Authorize] public async Task> GetWalletOverview( [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null ) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); try { var overview = await payment.GetWalletOverviewAsync( accountId: currentUser.Id, startDate: startDate, endDate: endDate ); return Ok(overview); } catch (Exception err) { return BadRequest(err.Message); } } }