785 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			785 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
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<NotificationResource> localizer,
 | 
						|
    INatsConnection nats
 | 
						|
)
 | 
						|
{
 | 
						|
    public async Task<SnWalletOrder> CreateOrderAsync(
 | 
						|
        Guid? payeeWalletId,
 | 
						|
        string currency,
 | 
						|
        decimal amount,
 | 
						|
        Duration? expiration = null,
 | 
						|
        string? appIdentifier = null,
 | 
						|
        string? productIdentifier = null,
 | 
						|
        string? remarks = null,
 | 
						|
        Dictionary<string, object>? 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<SnWalletTransaction> 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<SnWalletTransaction> 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<SnWalletOrder> 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<SnWalletOrder> 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<SnWalletTransaction> 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<SnWalletFund> CreateFundAsync(
 | 
						|
        Guid creatorAccountId,
 | 
						|
        List<Guid> 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<SnWallet>();
 | 
						|
        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<decimal> 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<decimal>();
 | 
						|
        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<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
 | 
						|
    {
 | 
						|
        var random = new Random();
 | 
						|
        var amounts = new List<decimal>();
 | 
						|
 | 
						|
        // 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<SnWalletTransaction> 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<WalletOverview> 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<string, TransactionSummary>()
 | 
						|
        };
 | 
						|
 | 
						|
        // 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<string, CurrencySummary>()
 | 
						|
                };
 | 
						|
            }
 | 
						|
 | 
						|
            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<string, TransactionSummary> 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<string, CurrencySummary> 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; }
 | 
						|
}
 |