using System.Globalization; using DysonNetwork.Pass.Localization; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Stream; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NATS.Client.Core; using NATS.Net; using NodaTime; using AccountService = DysonNetwork.Pass.Account.AccountService; namespace DysonNetwork.Pass.Wallet; public class PaymentService( AppDatabase db, WalletService wat, RingService.RingServiceClient pusher, IStringLocalizer localizer, INatsConnection nats ) { public async Task CreateOrderAsync( Guid? payeeWalletId, string currency, decimal amount, Duration? expiration = null, string? appIdentifier = null, string? productIdentifier = null, string? remarks = null, Dictionary? meta = null, bool reuseable = true ) { // Check if there's an existing unpaid order that can be reused var now = SystemClock.Instance.GetCurrentInstant(); if (reuseable && appIdentifier != null) { var existingOrder = await db.PaymentOrders .Where(o => o.Status == Shared.Models.OrderStatus.Unpaid && o.PayeeWalletId == payeeWalletId && o.Currency == currency && o.Amount == amount && o.AppIdentifier == appIdentifier && o.ProductIdentifier == productIdentifier && o.ExpiredAt > now) .FirstOrDefaultAsync(); // If an existing order is found, check if meta matches if (existingOrder != null && meta != null && existingOrder.Meta != null) { // Compare the meta dictionary - if they are equivalent, reuse the order var metaMatches = existingOrder.Meta.Count == meta.Count && !existingOrder.Meta.Except(meta).Any(); if (metaMatches) return existingOrder; } } // Create a new SnWalletOrder if no reusable order was found var order = new SnWalletOrder { PayeeWalletId = payeeWalletId, Currency = currency, Amount = amount, ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)), AppIdentifier = appIdentifier, ProductIdentifier = productIdentifier, Remarks = remarks, Meta = meta }; db.PaymentOrders.Add(order); await db.SaveChangesAsync(); return order; } public async Task CreateTransactionWithAccountAsync( Guid? payerAccountId, Guid? payeeAccountId, string currency, decimal amount, string? remarks = null, Shared.Models.TransactionType type = Shared.Models.TransactionType.System ) { SnWallet? payer = null, payee = null; if (payerAccountId.HasValue) payer = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerAccountId.Value); if (payeeAccountId.HasValue) payee = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeAccountId.Value); if (payer == null && payerAccountId.HasValue) throw new ArgumentException("Payer account was specified, but wallet was not found"); if (payee == null && payeeAccountId.HasValue) throw new ArgumentException("Payee account was specified, but wallet was not found"); return await CreateTransactionAsync( payer?.Id, payee?.Id, currency, amount, remarks, type ); } public async Task CreateTransactionAsync( Guid? payerWalletId, Guid? payeeWalletId, string currency, decimal amount, string? remarks = null, Shared.Models.TransactionType type = Shared.Models.TransactionType.System, bool silent = false ) { if (payerWalletId == null && payeeWalletId == null) throw new ArgumentException("At least one wallet must be specified."); if (amount <= 0) throw new ArgumentException("Cannot create transaction with negative or zero amount."); var transaction = new SnWalletTransaction { PayerWalletId = payerWalletId, PayeeWalletId = payeeWalletId, Currency = currency, Amount = amount, Remarks = remarks, Type = type }; SnWallet? payerWallet = null, payeeWallet = null; if (payerWalletId.HasValue) { payerWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerWalletId.Value); var (payerPocket, isNewlyCreated) = await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency); if (isNewlyCreated || payerPocket.Amount < amount) throw new InvalidOperationException("Insufficient funds"); await db.WalletPockets .Where(p => p.Id == payerPocket.Id && p.Amount >= amount) .ExecuteUpdateAsync(s => s.SetProperty(p => p.Amount, p => p.Amount - amount)); } if (payeeWalletId.HasValue) { payeeWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeWalletId.Value); var (payeePocket, isNewlyCreated) = await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount); if (!isNewlyCreated) await db.WalletPockets .Where(p => p.Id == payeePocket.Id) .ExecuteUpdateAsync(s => s.SetProperty(p => p.Amount, p => p.Amount + amount)); } db.PaymentTransactions.Add(transaction); await db.SaveChangesAsync(); if (!silent) await NotifyNewTransaction(transaction, payerWallet, payeeWallet); return transaction; } private async Task NotifyNewTransaction(SnWalletTransaction transaction, SnWallet? payerWallet, SnWallet? payeeWallet) { if (payerWallet is not null) { var account = await db.Accounts .Where(a => a.Id == payerWallet.AccountId) .FirstOrDefaultAsync(); if (account is null) return; AccountService.SetCultureInfo(account); // Due to ID is uuid, it longer than 8 words for sure var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8]; var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}"; await pusher.SendPushNotificationToUserAsync( new SendPushNotificationToUserRequest { UserId = account.Id.ToString(), Notification = new PushNotification { Topic = "wallets.transactions", Title = localizer["TransactionNewTitle", readableTransactionRemark], Body = transaction.Amount > 0 ? localizer["TransactionNewBodyMinus", transaction.Amount.ToString(CultureInfo.InvariantCulture), transaction.Currency] : localizer["TransactionNewBodyPlus", transaction.Amount.ToString(CultureInfo.InvariantCulture), transaction.Currency], IsSavable = true } } ); } if (payeeWallet is not null) { var account = await db.Accounts .Where(a => a.Id == payeeWallet.AccountId) .FirstOrDefaultAsync(); if (account is null) return; AccountService.SetCultureInfo(account); // Due to ID is uuid, it longer than 8 words for sure var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8]; var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}"; await pusher.SendPushNotificationToUserAsync( new SendPushNotificationToUserRequest { UserId = account.Id.ToString(), Notification = new PushNotification { Topic = "wallets.transactions", Title = localizer["TransactionNewTitle", readableTransactionRemark], Body = transaction.Amount > 0 ? localizer["TransactionNewBodyPlus", transaction.Amount.ToString(CultureInfo.InvariantCulture), transaction.Currency] : localizer["TransactionNewBodyMinus", transaction.Amount.ToString(CultureInfo.InvariantCulture), transaction.Currency], IsSavable = true } } ); } } public async Task PayOrderAsync(Guid orderId, SnWallet payerWallet) { var order = await db.PaymentOrders .Include(o => o.Transaction) .Include(o => o.PayeeWallet) .FirstOrDefaultAsync(o => o.Id == orderId) ?? throw new InvalidOperationException("Order not found"); var js = nats.CreateJetStreamContext(); if (order.Status == Shared.Models.OrderStatus.Paid) { await js.PublishAsync( PaymentOrderEventBase.Type, GrpcTypeHelper.ConvertObjectToByteString(new PaymentOrderEvent { OrderId = order.Id, WalletId = payerWallet.Id, AccountId = payerWallet.AccountId, AppIdentifier = order.AppIdentifier, ProductIdentifier = order.ProductIdentifier, Meta = order.Meta ?? [], Status = (int)order.Status, }).ToByteArray() ); return order; } if (order.Status != Shared.Models.OrderStatus.Unpaid) { throw new InvalidOperationException($"Order is in invalid status: {order.Status}"); } if (order.ExpiredAt < SystemClock.Instance.GetCurrentInstant()) { order.Status = Shared.Models.OrderStatus.Expired; await db.SaveChangesAsync(); throw new InvalidOperationException("Order has expired"); } var transaction = await CreateTransactionAsync( payerWallet.Id, order.PayeeWalletId, order.Currency, order.Amount, order.Remarks ?? $"Payment for Order #{order.Id}", type: Shared.Models.TransactionType.Order, silent: true); order.TransactionId = transaction.Id; order.Transaction = transaction; order.Status = Shared.Models.OrderStatus.Paid; await db.SaveChangesAsync(); await NotifyOrderPaid(order, payerWallet, order.PayeeWallet); await js.PublishAsync( PaymentOrderEventBase.Type, GrpcTypeHelper.ConvertObjectToByteString(new PaymentOrderEvent { OrderId = order.Id, WalletId = payerWallet.Id, AccountId = payerWallet.AccountId, AppIdentifier = order.AppIdentifier, ProductIdentifier = order.ProductIdentifier, Meta = order.Meta ?? [], Status = (int)order.Status, }).ToByteArray() ); return order; } private async Task NotifyOrderPaid(SnWalletOrder order, SnWallet? payerWallet, SnWallet? payeeWallet) { if (payerWallet is not null) { var account = await db.Accounts .Where(a => a.Id == payerWallet.AccountId) .FirstOrDefaultAsync(); if (account is null) return; AccountService.SetCultureInfo(account); // Due to ID is uuid, it longer than 8 words for sure var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; await pusher.SendPushNotificationToUserAsync( new SendPushNotificationToUserRequest { UserId = account.Id.ToString(), Notification = new PushNotification { Topic = "wallets.orders.paid", Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, readableOrderRemark], IsSavable = true } } ); } if (payeeWallet is not null) { var account = await db.Accounts .Where(a => a.Id == payeeWallet.AccountId) .FirstOrDefaultAsync(); if (account is null) return; AccountService.SetCultureInfo(account); // Due to ID is uuid, it longer than 8 words for sure var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; await pusher.SendPushNotificationToUserAsync( new SendPushNotificationToUserRequest { UserId = account.Id.ToString(), Notification = new PushNotification { Topic = "wallets.orders.received", Title = localizer["OrderReceivedTitle", $"#{readableOrderId}"], Body = localizer["OrderReceivedBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, readableOrderRemark], IsSavable = true } } ); } } public async Task CancelOrderAsync(Guid orderId) { var order = await db.PaymentOrders.FindAsync(orderId); if (order == null) { throw new InvalidOperationException("Order not found"); } if (order.Status != Shared.Models.OrderStatus.Unpaid) { throw new InvalidOperationException($"Cannot cancel order in status: {order.Status}"); } order.Status = Shared.Models.OrderStatus.Cancelled; await db.SaveChangesAsync(); return order; } public async Task<(SnWalletOrder Order, SnWalletTransaction RefundTransaction)> RefundOrderAsync(Guid orderId) { var order = await db.PaymentOrders .Include(o => o.Transaction) .FirstOrDefaultAsync(o => o.Id == orderId) ?? throw new InvalidOperationException("Order not found"); if (order.Status != Shared.Models.OrderStatus.Paid) { throw new InvalidOperationException($"Cannot refund order in status: {order.Status}"); } if (order.Transaction == null) { throw new InvalidOperationException("Order has no associated transaction"); } var refundTransaction = await CreateTransactionAsync( order.PayeeWalletId, order.Transaction.PayerWalletId, order.Currency, order.Amount, $"Refund for order {order.Id}"); order.Status = Shared.Models.OrderStatus.Finished; await db.SaveChangesAsync(); return (order, refundTransaction); } public async Task TransferAsync(Guid payerAccountId, Guid payeeAccountId, string currency, decimal amount) { var payerWallet = await wat.GetWalletAsync(payerAccountId); if (payerWallet == null) { throw new InvalidOperationException($"Payer wallet not found for account {payerAccountId}"); } var payeeWallet = await wat.GetWalletAsync(payeeAccountId); if (payeeWallet == null) { throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}"); } // Calculate transfer fee (5%) decimal fee = Math.Round(amount * 0.05m, 2); decimal finalCost = amount + fee; // Make sure the account has sufficient balanace for both fee and the transfer var (payerPocket, isNewlyCreated) = await wat.GetOrCreateWalletPocketAsync(payerWallet.Id, currency, amount); if (isNewlyCreated || payerPocket.Amount < finalCost) throw new InvalidOperationException("Insufficient funds"); // Create main transfer transaction var transaction = await CreateTransactionAsync( payerWallet.Id, payeeWallet.Id, currency, amount, $"Transfer from account {payerAccountId} to {payeeAccountId}", Shared.Models.TransactionType.Transfer); // Create fee transaction (to system) await CreateTransactionAsync( payerWallet.Id, null, currency, fee, $"Transfer fee for transaction #{transaction.Id}", Shared.Models.TransactionType.System); return transaction; } public async Task CreateFundAsync( Guid creatorAccountId, List recipientAccountIds, string currency, decimal totalAmount, Shared.Models.FundSplitType splitType, string? message = null, Duration? expiration = null) { if (recipientAccountIds.Count == 0) throw new ArgumentException("At least one recipient is required"); if (totalAmount <= 0) throw new ArgumentException("Total amount must be positive"); // Validate all recipient accounts exist and have wallets var recipientWallets = new List(); foreach (var accountId in recipientAccountIds) { var wallet = await wat.GetWalletAsync(accountId); if (wallet == null) throw new InvalidOperationException($"Wallet not found for recipient account {accountId}"); recipientWallets.Add(wallet); } // Check creator has sufficient funds var creatorWallet = await wat.GetWalletAsync(creatorAccountId); if (creatorWallet == null) throw new InvalidOperationException($"Creator wallet not found for account {creatorAccountId}"); var (creatorPocket, _) = await wat.GetOrCreateWalletPocketAsync(creatorWallet.Id, currency); if (creatorPocket.Amount < totalAmount) throw new InvalidOperationException("Insufficient funds"); // Calculate amounts for each recipient var recipientAmounts = splitType switch { Shared.Models.FundSplitType.Even => SplitEvenly(totalAmount, recipientAccountIds.Count), Shared.Models.FundSplitType.Random => SplitRandomly(totalAmount, recipientAccountIds.Count), _ => throw new ArgumentException("Invalid split type") }; var now = SystemClock.Instance.GetCurrentInstant(); var fund = new SnWalletFund { CreatorAccountId = creatorAccountId, Currency = currency, TotalAmount = totalAmount, SplitType = splitType, Message = message, ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)), Recipients = recipientAccountIds.Select((accountId, index) => new SnWalletFundRecipient { RecipientAccountId = accountId, Amount = recipientAmounts[index] }).ToList() }; // Deduct from creator's wallet await db.WalletPockets .Where(p => p.Id == creatorPocket.Id) .ExecuteUpdateAsync(s => s.SetProperty(p => p.Amount, p => p.Amount - totalAmount)); db.WalletFunds.Add(fund); await db.SaveChangesAsync(); // Load the fund with account data including profiles var createdFund = 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 == fund.Id); return createdFund!; } private List SplitEvenly(decimal totalAmount, int recipientCount) { var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100; // Round down to 2 decimal places var remainder = totalAmount - (baseAmount * recipientCount); var amounts = new List(); for (int i = 0; i < recipientCount; i++) { var amount = baseAmount; if (i < remainder * 100) // Distribute remainder as 0.01 increments amount += 0.01m; amounts.Add(amount); } return amounts; } private List SplitRandomly(decimal totalAmount, int recipientCount) { var random = new Random(); var amounts = new List(); // Generate random amounts that sum to total decimal remaining = totalAmount; for (int i = 0; i < recipientCount - 1; i++) { // Ensure each recipient gets at least 0.01 and leave enough for remaining recipients var maxAmount = remaining - (recipientCount - i - 1) * 0.01m; var minAmount = 0.01m; var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2); amounts.Add(amount); remaining -= amount; } // Last recipient gets the remainder amounts.Add(Math.Round(remaining, 2)); return amounts; } public async Task ReceiveFundAsync(Guid recipientAccountId, Guid fundId) { var fund = await db.WalletFunds .Include(f => f.Recipients) .FirstOrDefaultAsync(f => f.Id == fundId); if (fund == null) throw new InvalidOperationException("Fund not found"); if (fund.Status == Shared.Models.FundStatus.Expired || fund.Status == Shared.Models.FundStatus.Refunded) throw new InvalidOperationException("Fund is no longer available"); var recipient = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId); if (recipient == null) throw new InvalidOperationException("You are not a recipient of this fund"); if (recipient.IsReceived) throw new InvalidOperationException("You have already received this fund"); var recipientWallet = await wat.GetWalletAsync(recipientAccountId); if (recipientWallet == null) throw new InvalidOperationException("Recipient wallet not found"); // Create transaction to transfer funds to recipient var transaction = await CreateTransactionAsync( payerWalletId: null, // System transfer payeeWalletId: recipientWallet.Id, currency: fund.Currency, amount: recipient.Amount, remarks: $"Received fund portion from {fund.CreatorAccountId}", type: Shared.Models.TransactionType.System, silent: true ); // Mark as received recipient.IsReceived = true; recipient.ReceivedAt = SystemClock.Instance.GetCurrentInstant(); // Update fund status var allReceived = fund.Recipients.All(r => r.IsReceived); if (allReceived) fund.Status = Shared.Models.FundStatus.FullyReceived; else fund.Status = Shared.Models.FundStatus.PartiallyReceived; await db.SaveChangesAsync(); return transaction; } public async Task ProcessExpiredFundsAsync() { var now = SystemClock.Instance.GetCurrentInstant(); var expiredFunds = await db.WalletFunds .Include(f => f.Recipients) .Where(f => f.Status == Shared.Models.FundStatus.Created || f.Status == Shared.Models.FundStatus.PartiallyReceived) .Where(f => f.ExpiredAt < now) .ToListAsync(); foreach (var fund in expiredFunds) { // Calculate unclaimed amount var unclaimedAmount = fund.Recipients .Where(r => !r.IsReceived) .Sum(r => r.Amount); if (unclaimedAmount > 0) { // Refund to creator var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId); if (creatorWallet != null) { await CreateTransactionAsync( payerWalletId: null, // System refund payeeWalletId: creatorWallet.Id, currency: fund.Currency, amount: unclaimedAmount, remarks: $"Refund for expired fund {fund.Id}", type: Shared.Models.TransactionType.System, silent: true ); } } fund.Status = Shared.Models.FundStatus.Expired; } await db.SaveChangesAsync(); } public async Task GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null, DateTime? endDate = null) { var wallet = await wat.GetWalletAsync(accountId); if (wallet == null) throw new InvalidOperationException("Wallet not found"); var query = db.PaymentTransactions .Where(t => t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id); if (startDate.HasValue) query = query.Where(t => t.CreatedAt >= Instant.FromDateTimeUtc(startDate.Value.ToUniversalTime())); if (endDate.HasValue) query = query.Where(t => t.CreatedAt <= Instant.FromDateTimeUtc(endDate.Value.ToUniversalTime())); var transactions = await query.ToListAsync(); var overview = new WalletOverview { AccountId = accountId, StartDate = startDate?.ToString("O"), EndDate = endDate?.ToString("O"), Summary = new Dictionary() }; // Group transactions by type and currency var groupedTransactions = transactions .GroupBy(t => new { t.Type, t.Currency }) .ToDictionary(g => g.Key, g => g.ToList()); foreach (var group in groupedTransactions) { var typeName = group.Key.Type.ToString(); var currency = group.Key.Currency; if (!overview.Summary.ContainsKey(typeName)) { overview.Summary[typeName] = new TransactionSummary { Type = typeName, Currencies = new Dictionary() }; } var currencySummary = new CurrencySummary { Currency = currency, Income = 0, Spending = 0, Net = 0 }; foreach (var transaction in group.Value) { if (transaction.PayeeWalletId == wallet.Id) { // Money coming in currencySummary.Income += transaction.Amount; } else if (transaction.PayerWalletId == wallet.Id) { // Money going out currencySummary.Spending += transaction.Amount; } } currencySummary.Net = currencySummary.Income - currencySummary.Spending; overview.Summary[typeName].Currencies[currency] = currencySummary; } // Calculate totals overview.TotalIncome = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Income)); overview.TotalSpending = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Spending)); overview.NetTotal = overview.TotalIncome - overview.TotalSpending; return overview; } } public class WalletOverview { public Guid AccountId { get; set; } public string? StartDate { get; set; } public string? EndDate { get; set; } public Dictionary Summary { get; set; } = new(); public decimal TotalIncome { get; set; } public decimal TotalSpending { get; set; } public decimal NetTotal { get; set; } } public class TransactionSummary { public string Type { get; set; } = null!; public Dictionary Currencies { get; set; } = new(); } public class CurrencySummary { public string Currency { get; set; } = null!; public decimal Income { get; set; } public decimal Spending { get; set; } public decimal Net { get; set; } }