diff --git a/src/CMSMicroservice.Application/Common/Exceptions/PaymentInProgressException.cs b/src/CMSMicroservice.Application/Common/Exceptions/PaymentInProgressException.cs new file mode 100644 index 0000000..631a6ad --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Exceptions/PaymentInProgressException.cs @@ -0,0 +1,22 @@ +namespace CMSMicroservice.Application.Common.Exceptions; + +/// +/// Thrown when a concurrent payment operation is already in progress for the same scope. +/// +public class PaymentInProgressException : Exception +{ + public PaymentInProgressException() + : base("یک عملیات پرداخت دیگر در حال پردازش است. لطفاً چند لحظه صبر کنید.") + { + } + + public PaymentInProgressException(string message) + : base(message) + { + } + + public PaymentInProgressException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/CMSMicroservice.Application/Common/Interfaces/IUserPaymentLock.cs b/src/CMSMicroservice.Application/Common/Interfaces/IUserPaymentLock.cs new file mode 100644 index 0000000..aa2c9c2 --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Interfaces/IUserPaymentLock.cs @@ -0,0 +1,32 @@ +namespace CMSMicroservice.Application.Common.Interfaces; + +/// +/// Per-scope in-process mutex for payment flows (initiate / verify). +/// Prevents the same user from running duplicate gateway operations concurrently. +/// +public interface IUserPaymentLock +{ + Task ExecuteAsync( + string scope, + PaymentLockStrategy strategy, + Func> action, + CancellationToken cancellationToken = default); + + Task ExecuteAsync( + string scope, + PaymentLockStrategy strategy, + Func action, + CancellationToken cancellationToken = default); +} + +/// +/// How to behave when the payment lock is already held. +/// +public enum PaymentLockStrategy +{ + /// Wait up to the configured timeout (callback / verify paths). + WaitForRelease, + + /// Reject immediately (initiate / double-click paths). + FailFast +} diff --git a/src/CMSMicroservice.Application/Common/PaymentLockScopes.cs b/src/CMSMicroservice.Application/Common/PaymentLockScopes.cs new file mode 100644 index 0000000..3fb78e7 --- /dev/null +++ b/src/CMSMicroservice.Application/Common/PaymentLockScopes.cs @@ -0,0 +1,19 @@ +namespace CMSMicroservice.Application.Common; + +/// +/// Canonical scope keys for . +/// +public static class PaymentLockScopes +{ + /// One active payment initiation per user (package, wallet charge, order, IPG deposit). + public static string Initiate(long userId) => $"payment:initiate:user:{userId}"; + + /// One active verify per user + authority/order (duplicate callback protection). + public static string Verify(long userId, string resourceKey) + { + if (string.IsNullOrWhiteSpace(resourceKey)) + throw new ArgumentException("Payment verify resource key is required.", nameof(resourceKey)); + + return $"payment:verify:user:{userId}:{resourceKey.Trim()}"; + } +} diff --git a/src/CMSMicroservice.Application/DiscountShopCQ/Commands/PlaceOrder/PlaceOrderCommandHandler.cs b/src/CMSMicroservice.Application/DiscountShopCQ/Commands/PlaceOrder/PlaceOrderCommandHandler.cs index 35fa80e..37690a1 100644 --- a/src/CMSMicroservice.Application/DiscountShopCQ/Commands/PlaceOrder/PlaceOrderCommandHandler.cs +++ b/src/CMSMicroservice.Application/DiscountShopCQ/Commands/PlaceOrder/PlaceOrderCommandHandler.cs @@ -1,3 +1,5 @@ +using CMSMicroservice.Application.Common; +using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Services; using CMSMicroservice.Domain.Entities.DiscountShop; @@ -17,22 +19,32 @@ public class PlaceOrderCommandHandler : IRequestHandler _logger; + private readonly IUserPaymentLock _paymentLock; public PlaceOrderCommandHandler( IApplicationDbContext context, IInventoryService inventoryService, IPaymentGatewayService paymentGateway, IConfiguration configuration, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _inventoryService = inventoryService; _paymentGateway = paymentGateway; _configuration = configuration; _logger = logger; + _paymentLock = paymentLock; } - public async Task Handle(PlaceOrderCommand request, CancellationToken cancellationToken) + public Task Handle(PlaceOrderCommand request, CancellationToken cancellationToken) => + _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(request.UserId), + PaymentLockStrategy.FailFast, + ct => HandleCore(request, ct), + cancellationToken); + + private async Task HandleCore(PlaceOrderCommand request, CancellationToken cancellationToken) { // Get user wallet var userWallet = await _context.UserWallets diff --git a/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeDiscountWallet/ChargeDiscountWalletCommandHandler.cs b/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeDiscountWallet/ChargeDiscountWalletCommandHandler.cs index 411d570..d38a076 100644 --- a/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeDiscountWallet/ChargeDiscountWalletCommandHandler.cs +++ b/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeDiscountWallet/ChargeDiscountWalletCommandHandler.cs @@ -1,3 +1,4 @@ +using CMSMicroservice.Application.Common; using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Models; @@ -17,20 +18,32 @@ public class ChargeDiscountWalletCommandHandler private readonly IPaymentGatewayService _paymentGateway; private readonly IConfiguration _configuration; private readonly ILogger _logger; + private readonly IUserPaymentLock _paymentLock; public ChargeDiscountWalletCommandHandler( IApplicationDbContext context, IPaymentGatewayService paymentGateway, IConfiguration configuration, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _paymentGateway = paymentGateway; _configuration = configuration; _logger = logger; + _paymentLock = paymentLock; } - public async Task Handle( + public Task Handle( + ChargeDiscountWalletCommand request, + CancellationToken cancellationToken) => + _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(request.UserId), + PaymentLockStrategy.FailFast, + ct => HandleCore(request, ct), + cancellationToken); + + private async Task HandleCore( ChargeDiscountWalletCommand request, CancellationToken cancellationToken) { diff --git a/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeMagicWallet/ChargeMagicWalletCommandHandler.cs b/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeMagicWallet/ChargeMagicWalletCommandHandler.cs index fd9ece1..18f328e 100644 --- a/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeMagicWallet/ChargeMagicWalletCommandHandler.cs +++ b/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeMagicWallet/ChargeMagicWalletCommandHandler.cs @@ -1,3 +1,4 @@ +using CMSMicroservice.Application.Common; using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Domain.Common; @@ -18,20 +19,32 @@ public class ChargeMagicWalletCommandHandler private readonly IPaymentGatewayService _paymentGateway; private readonly IConfiguration _configuration; private readonly ILogger _logger; + private readonly IUserPaymentLock _paymentLock; public ChargeMagicWalletCommandHandler( IApplicationDbContext context, IPaymentGatewayService paymentGateway, IConfiguration configuration, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _paymentGateway = paymentGateway; _configuration = configuration; _logger = logger; + _paymentLock = paymentLock; } - public async Task Handle( + public Task Handle( + ChargeMagicWalletCommand request, + CancellationToken cancellationToken) => + _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(request.UserId), + PaymentLockStrategy.FailFast, + ct => HandleCore(request, ct), + cancellationToken); + + private async Task HandleCore( ChargeMagicWalletCommand request, CancellationToken cancellationToken) { diff --git a/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyDiscountWalletCharge/VerifyDiscountWalletChargeCommandHandler.cs b/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyDiscountWalletCharge/VerifyDiscountWalletChargeCommandHandler.cs index a5694d5..ec45fc2 100644 --- a/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyDiscountWalletCharge/VerifyDiscountWalletChargeCommandHandler.cs +++ b/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyDiscountWalletCharge/VerifyDiscountWalletChargeCommandHandler.cs @@ -1,3 +1,4 @@ +using CMSMicroservice.Application.Common; using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Models; @@ -15,18 +16,30 @@ public class VerifyDiscountWalletChargeCommandHandler private readonly IApplicationDbContext _context; private readonly IPaymentGatewayService _paymentGateway; private readonly ILogger _logger; + private readonly IUserPaymentLock _paymentLock; public VerifyDiscountWalletChargeCommandHandler( IApplicationDbContext context, IPaymentGatewayService paymentGateway, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _paymentGateway = paymentGateway; _logger = logger; + _paymentLock = paymentLock; } - public async Task Handle( + public Task Handle( + VerifyDiscountWalletChargeCommand request, + CancellationToken cancellationToken) => + _paymentLock.ExecuteAsync( + PaymentLockScopes.Verify(request.UserId, request.Authority), + PaymentLockStrategy.WaitForRelease, + ct => HandleCore(request, ct), + cancellationToken); + + private async Task HandleCore( VerifyDiscountWalletChargeCommand request, CancellationToken cancellationToken) { diff --git a/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyMagicWalletCharge/VerifyMagicWalletChargeCommandHandler.cs b/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyMagicWalletCharge/VerifyMagicWalletChargeCommandHandler.cs index c455e4e..f90e7b2 100644 --- a/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyMagicWalletCharge/VerifyMagicWalletChargeCommandHandler.cs +++ b/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyMagicWalletCharge/VerifyMagicWalletChargeCommandHandler.cs @@ -1,3 +1,4 @@ +using CMSMicroservice.Application.Common; using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Domain.Common; @@ -16,20 +17,44 @@ public class VerifyMagicWalletChargeCommandHandler private readonly IApplicationDbContext _context; private readonly IPaymentGatewayService _paymentGateway; private readonly ILogger _logger; + private readonly IUserPaymentLock _paymentLock; public VerifyMagicWalletChargeCommandHandler( IApplicationDbContext context, IPaymentGatewayService paymentGateway, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _paymentGateway = paymentGateway; _logger = logger; + _paymentLock = paymentLock; } public async Task Handle( VerifyMagicWalletChargeCommand request, CancellationToken cancellationToken) + { + var paymentTx = await _context.PaymentTransactions + .AsNoTracking() + .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken); + + if (paymentTx == null) + throw new NotFoundException("تراکنش پرداخت یافت نشد"); + + if (!paymentTx.UserId.HasValue) + throw new BadRequestException("شناسه کاربر در تراکنش پرداخت یافت نشد"); + + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Verify(paymentTx.UserId.Value, request.Authority), + PaymentLockStrategy.WaitForRelease, + ct => HandleCore(request, ct), + cancellationToken); + } + + private async Task HandleCore( + VerifyMagicWalletChargeCommand request, + CancellationToken cancellationToken) { try { diff --git a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs index 3c96121..8890d70 100644 --- a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs +++ b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs @@ -39,6 +39,7 @@ public static class ConfigureServices services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); // Local file manager — files are saved to wwwroot/uploads/ on CMS disk services.AddSingleton(); services.AddScoped(); diff --git a/src/CMSMicroservice.Infrastructure/Services/UserPaymentLockService.cs b/src/CMSMicroservice.Infrastructure/Services/UserPaymentLockService.cs new file mode 100644 index 0000000..9137915 --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Services/UserPaymentLockService.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using CMSMicroservice.Application.Common.Exceptions; +using CMSMicroservice.Application.Common.Interfaces; +using Microsoft.Extensions.Logging; + +namespace CMSMicroservice.Infrastructure.Services; + +/// +/// In-process keyed semaphore lock for payment operations. +/// Safe for multi-threaded gRPC/MediatR handlers within a single CMS instance. +/// +public sealed class UserPaymentLockService : IUserPaymentLock, IDisposable +{ + private static readonly TimeSpan VerifyWaitTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan StaleEntryAge = TimeSpan.FromMinutes(30); + private static readonly TimeSpan CleanupInterval = TimeSpan.FromMinutes(5); + + private readonly ConcurrentDictionary _entries = new(); + private readonly ILogger _logger; + private readonly Timer _cleanupTimer; + private int _disposed; + + public UserPaymentLockService(ILogger logger) + { + _logger = logger; + _cleanupTimer = new Timer(_ => CleanupStaleEntries(), null, CleanupInterval, CleanupInterval); + } + + public Task ExecuteAsync( + string scope, + PaymentLockStrategy strategy, + Func> action, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + ArgumentNullException.ThrowIfNull(action); + + return ExecuteCoreAsync(scope, strategy, action, cancellationToken); + } + + public async Task ExecuteAsync( + string scope, + PaymentLockStrategy strategy, + Func action, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + ArgumentNullException.ThrowIfNull(action); + + await ExecuteCoreAsync( + scope, + strategy, + async ct => + { + await action(ct); + return true; + }, + cancellationToken); + } + + private async Task ExecuteCoreAsync( + string scope, + PaymentLockStrategy strategy, + Func> action, + CancellationToken cancellationToken) + { + var entry = _entries.GetOrAdd(scope, static _ => new LockEntry()); + entry.Touch(); + + var waitTimeout = strategy == PaymentLockStrategy.FailFast + ? TimeSpan.Zero + : VerifyWaitTimeout; + + Interlocked.Increment(ref entry.WaiterCount); + + var acquired = false; + try + { + acquired = await entry.Semaphore.WaitAsync(waitTimeout, cancellationToken); + if (!acquired) + { + _logger.LogWarning( + "Payment lock busy. Scope={Scope}, Strategy={Strategy}", + scope, + strategy); + + throw new PaymentInProgressException( + strategy == PaymentLockStrategy.FailFast + ? "درخواست پرداخت دیگری در حال پردازش است. لطفاً صبر کنید." + : "تأیید پرداخت در حال انجام است. لطفاً چند لحظه صبر کنید."); + } + + entry.Touch(); + return await action(cancellationToken); + } + finally + { + if (acquired) + entry.Semaphore.Release(); + + if (Interlocked.Decrement(ref entry.WaiterCount) == 0 && entry.CanRemove) + _entries.TryRemove(scope, out _); + } + } + + private void CleanupStaleEntries() + { + if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) + return; + + var cutoff = DateTime.UtcNow - StaleEntryAge; + foreach (var (scope, entry) in _entries) + { + if (entry.LastUsedUtc < cutoff && entry.CanRemove) + _entries.TryRemove(scope, out _); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + return; + + _cleanupTimer.Dispose(); + + foreach (var entry in _entries.Values) + entry.Semaphore.Dispose(); + + _entries.Clear(); + } + + private sealed class LockEntry + { + public SemaphoreSlim Semaphore { get; } = new(1, 1); + public int WaiterCount; + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + public void Touch() => LastUsedUtc = DateTime.UtcNow; + + public bool CanRemove => + Volatile.Read(ref WaiterCount) == 0 && Semaphore.CurrentCount == 1; + } +} diff --git a/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs b/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs index f5967cd..a209882 100644 --- a/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs +++ b/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs @@ -1,5 +1,7 @@ using CMSMicroservice.Protobuf.Protos.DiscountOrder; using CMSMicroservice.WebApi.Common.Services; +using CMSMicroservice.Application.Common; +using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder; using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment; @@ -26,6 +28,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB private readonly IApplicationDbContext _context; private readonly IPaymentGatewayService _paymentGateway; private readonly ILogger _logger; + private readonly IUserPaymentLock _paymentLock; public DiscountOrderService( IDispatchRequestToCQRS dispatchRequestToCQRS, @@ -33,7 +36,8 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB ICurrentUserService currentUserService, IApplicationDbContext context, IPaymentGatewayService paymentGateway, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; @@ -41,6 +45,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB _context = context; _paymentGateway = paymentGateway; _logger = logger; + _paymentLock = paymentLock; } private long GetCurrentUserId() @@ -58,20 +63,32 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB UserAddressId = request.UserAddressId, DiscountBalanceToUse = request.DiscountBalanceToUse }; - var result = await _sender.Send(command); - - var response = new PlaceOrderResponse + + try { - Success = result.Success, - Message = result.Message ?? string.Empty, - OrderId = result.OrderId ?? 0, - GatewayAmount = result.GatewayAmountRequired, - }; + var result = await _sender.Send(command); - if (!string.IsNullOrEmpty(result.PaymentUrl)) - response.PaymentUrl = result.PaymentUrl; + var response = new PlaceOrderResponse + { + Success = result.Success, + Message = result.Message ?? string.Empty, + OrderId = result.OrderId ?? 0, + GatewayAmount = result.GatewayAmountRequired, + }; - return response; + if (!string.IsNullOrEmpty(result.PaymentUrl)) + response.PaymentUrl = result.PaymentUrl; + + return response; + } + catch (PaymentInProgressException ex) + { + return new PlaceOrderResponse + { + Success = false, + Message = ex.Message + }; + } } public override async Task CompleteOrderPayment(CompleteOrderPaymentRequest request, ServerCallContext context) @@ -193,6 +210,48 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB public override async Task CustomerVerifyDiscountOrderPayment( CustomerVerifyDiscountOrderPaymentRequest request, ServerCallContext context) + { + var orderOwner = await _context.DiscountOrders + .Where(o => o.Id == request.OrderId) + .Select(o => (long?)o.UserId) + .FirstOrDefaultAsync(context.CancellationToken); + + if (orderOwner is null or 0) + { + return new CustomerVerifyDiscountOrderPaymentResponse + { + Success = false, + Message = "سفارش یافت نشد", + OrderId = request.OrderId + }; + } + + var verifyKey = !string.IsNullOrEmpty(request.Authority) + ? request.Authority + : request.OrderId.ToString(); + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Verify(orderOwner.Value, verifyKey), + PaymentLockStrategy.WaitForRelease, + ct => CustomerVerifyDiscountOrderPaymentCore(request, ct), + context.CancellationToken); + } + catch (PaymentInProgressException ex) + { + return new CustomerVerifyDiscountOrderPaymentResponse + { + Success = false, + Message = ex.Message, + OrderId = request.OrderId + }; + } + } + + private async Task CustomerVerifyDiscountOrderPaymentCore( + CustomerVerifyDiscountOrderPaymentRequest request, + CancellationToken cancellationToken) { _logger.LogInformation( "CustomerVerifyDiscountOrderPayment called: OrderId={OrderId}, Authority={Authority}, Status={Status}", @@ -203,7 +262,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB // پیدا کردن سفارش و تراکنش var order = await _context.DiscountOrders .Include(o => o.OrderDetails) - .FirstOrDefaultAsync(o => o.Id == request.OrderId, context.CancellationToken); + .FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken); if (order == null) { @@ -218,7 +277,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB var transaction = order.TransactionId.HasValue ? await _context.Transactions.FirstOrDefaultAsync( - t => t.Id == order.TransactionId.Value, context.CancellationToken) + t => t.Id == order.TransactionId.Value, cancellationToken) : null; // تأیید پرداخت از درگاه @@ -233,14 +292,14 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB request.Authority, request.Status, order.GatewayAmountPaid, - context.CancellationToken); + cancellationToken); paymentSuccess = verifyResult.IsSuccess; refId = verifyResult.TrackingCode ?? verifyResult.RefId; // آپدیت PaymentTransaction با نتیجه verify var paymentTx = await _context.PaymentTransactions - .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken); + .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken); if (paymentTx != null) { paymentTx.PaymentStatus = verifyResult.IsSuccess; @@ -249,7 +308,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB paymentTx.CardPan = verifyResult.CardPan; paymentTx.CardHash = verifyResult.CardHash; paymentTx.RefId = verifyResult.TrackingCode; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); } _logger.LogInformation( @@ -268,7 +327,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB TransactionId = transaction?.Id ?? 0, PaymentSuccess = paymentSuccess, RefId = refId - }, context.CancellationToken); + }, cancellationToken); return new CustomerVerifyDiscountOrderPaymentResponse { diff --git a/src/CMSMicroservice.WebApi/Services/PackageService.cs b/src/CMSMicroservice.WebApi/Services/PackageService.cs index 3cbae2f..1b8bf84 100644 --- a/src/CMSMicroservice.WebApi/Services/PackageService.cs +++ b/src/CMSMicroservice.WebApi/Services/PackageService.cs @@ -10,11 +10,14 @@ using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackages; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackageDetails; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPurchaseHistory; using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Application.Common; +using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Domain.Entities.Payment; using AppModels = CMSMicroservice.Application.Common.Models; using Grpc.Core; using Google.Protobuf.WellKnownTypes; using System.Collections.Generic; +using System.Data; using System.Linq; using CMSMicroservice.Protobuf.Protos; using Microsoft.EntityFrameworkCore; @@ -31,6 +34,7 @@ public class PackageService : PackageContract.PackageContractBase private readonly ICurrentUserService _currentUserService; private readonly IPaymentGatewayService _paymentGateway; private readonly IConfiguration _configuration; + private readonly IUserPaymentLock _paymentLock; public PackageService( IDispatchRequestToCQRS dispatchRequestToCQRS, @@ -38,7 +42,8 @@ public class PackageService : PackageContract.PackageContractBase IApplicationDbContext context, ICurrentUserService currentUserService, IPaymentGatewayService paymentGateway, - IConfiguration configuration) + IConfiguration configuration, + IUserPaymentLock paymentLock) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; @@ -46,6 +51,7 @@ public class PackageService : PackageContract.PackageContractBase _currentUserService = currentUserService; _paymentGateway = paymentGateway; _configuration = configuration; + _paymentLock = paymentLock; } public override async Task CreateNewPackage(CreateNewPackageRequest request, ServerCallContext context) { @@ -137,12 +143,31 @@ public class PackageService : PackageContract.PackageContractBase public override async Task CustomerPurchasePackage(CustomerPurchasePackageRequest request, ServerCallContext context) { var userId = GetCurrentUserId(); - + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(userId), + PaymentLockStrategy.FailFast, + ct => CustomerPurchasePackageCore(request, userId, ct), + context.CancellationToken); + } + catch (PaymentInProgressException ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); + } + } + + private async Task CustomerPurchasePackageCore( + CustomerPurchasePackageRequest request, + long userId, + CancellationToken cancellationToken) + { // Lookup package var package = await _context.Packages .AsNoTracking() .Where(p => p.Id == request.PackageId && !p.IsDeleted) - .FirstOrDefaultAsync(context.CancellationToken); + .FirstOrDefaultAsync(cancellationToken); if (package == null) throw new RpcException(new Status(StatusCode.NotFound, "پکیج مورد نظر یافت نشد")); @@ -156,7 +181,7 @@ public class PackageService : PackageContract.PackageContractBase Type = Domain.Enums.TransactionType.Buy }; _context.Transactions.Add(transaction); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); // Create purchase record var purchaseMethod = request.PurchaseMethod == PurchaseMethodEnum.PurchaseMethodGateway @@ -173,14 +198,14 @@ public class PackageService : PackageContract.PackageContractBase TransactionId = transaction.Id }; _context.UserPackagePurchases.Add(purchase); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); // Initiate payment with gateway var user = await _context.Users .AsNoTracking() .Where(u => u.Id == userId) .Select(u => new { u.Mobile }) - .FirstOrDefaultAsync(context.CancellationToken); + .FirstOrDefaultAsync(cancellationToken); // Callback URL از config — نه از ورودی کاربر (امنیت) var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268"; @@ -193,12 +218,12 @@ public class PackageService : PackageContract.PackageContractBase Mobile = user?.Mobile ?? string.Empty, Description = $"خرید پکیج {package.Title}", CallbackUrl = callbackUrl - }, context.CancellationToken); + }, cancellationToken); if (!paymentResult.IsSuccess) { transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPurchasePackageResponse { @@ -228,7 +253,7 @@ public class PackageService : PackageContract.PackageContractBase OrderId = purchase.Id.ToString() }; _context.PaymentTransactions.Add(paymentTx); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPurchasePackageResponse { @@ -242,66 +267,167 @@ public class PackageService : PackageContract.PackageContractBase public override async Task CustomerVerifyPackagePurchase(CustomerVerifyPackagePurchaseRequest request, ServerCallContext context) { - // Find purchase record + var ct = context.CancellationToken; + + var purchaseUserId = await _context.UserPackagePurchases + .Where(p => p.Id == request.OrderId && !p.IsDeleted) + .Select(p => (long?)p.UserId) + .FirstOrDefaultAsync(ct); + + if (purchaseUserId is null or 0) + throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); + + var verifyKey = !string.IsNullOrEmpty(request.Authority) + ? request.Authority + : request.OrderId.ToString(); + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Verify(purchaseUserId.Value, verifyKey), + PaymentLockStrategy.WaitForRelease, + lockCt => CustomerVerifyPackagePurchaseCore(request, lockCt), + ct); + } + catch (PaymentInProgressException ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); + } + } + + private async Task CustomerVerifyPackagePurchaseCore( + CustomerVerifyPackagePurchaseRequest request, + CancellationToken ct) + { var purchase = await _context.UserPackagePurchases .Include(p => p.Package) .Where(p => p.Id == request.OrderId && !p.IsDeleted) - .FirstOrDefaultAsync(context.CancellationToken); - + .FirstOrDefaultAsync(ct); + if (purchase == null) throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); - - // Find the associated transaction + var transaction = purchase.TransactionId.HasValue ? await _context.Transactions .Where(t => t.Id == purchase.TransactionId.Value) - .FirstOrDefaultAsync(context.CancellationToken) + .FirstOrDefaultAsync(ct) : null; - - // If status from gateway callback is not OK + + var paymentTx = !string.IsNullOrEmpty(request.Authority) + ? await _context.PaymentTransactions + .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, ct) + : null; + + // Idempotency: already verified — never credit wallet again + if (IsPackagePaymentAlreadyCompleted(transaction, paymentTx)) + { + return BuildVerifyPackagePurchaseResponse( + purchase, transaction, paymentTx, alreadyPaid: true); + } + if (request.Status != "OK") { - if (transaction != null) + if (transaction != null && transaction.PaymentStatus != Domain.Enums.PaymentStatus.Success) { transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(ct); } - + return new CustomerVerifyPackagePurchaseResponse { Success = false, Message = "پرداخت توسط کاربر لغو شد" }; } - - // واکشی PaymentTransaction برای گرفتن مبلغ (تومان) جهت verify - var paymentTx = await _context.PaymentTransactions - .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken); - + var amountInToman = paymentTx?.Amount ?? purchase.Amount; - - // Verify with payment gateway (مبلغ به تومان — سرویس زرین‌پال خودش ×۱۰ می‌کنه) var verifyResult = await _paymentGateway.VerifyPaymentAsync( - request.Authority, request.Status, (decimal)amountInToman, context.CancellationToken); - if (paymentTx != null) + request.Authority, request.Status, (decimal)amountInToman, ct); + + if (!verifyResult.IsSuccess) { - paymentTx.PaymentStatus = verifyResult.IsSuccess; - paymentTx.VerificationStatusCode = verifyResult.VerificationCode; - paymentTx.VerificationStatusMessage = verifyResult.Message; - paymentTx.CardPan = verifyResult.CardPan; - paymentTx.CardHash = verifyResult.CardHash; - paymentTx.RefId = verifyResult.TrackingCode; + if (paymentTx != null) + { + paymentTx.PaymentStatus = false; + paymentTx.VerificationStatusCode = verifyResult.VerificationCode; + paymentTx.VerificationStatusMessage = verifyResult.Message; + } + if (transaction != null) + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; + + await _context.SaveChangesAsync(ct); + + return new CustomerVerifyPackagePurchaseResponse + { + Success = false, + Message = verifyResult.Message ?? "خرید پکیج ناموفق بود", + TransactionId = transaction?.Id ?? 0 + }; } - - if (verifyResult.IsSuccess && transaction != null) + + // Serializable transaction prevents concurrent double-credit (~1s race from duplicate callbacks) + await using var dbTx = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + try { + // Reload inside lock scope + if (purchase.TransactionId.HasValue) + { + transaction = await _context.Transactions + .FirstOrDefaultAsync(t => t.Id == purchase.TransactionId.Value, ct); + } + if (!string.IsNullOrEmpty(request.Authority)) + { + paymentTx = await _context.PaymentTransactions + .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, ct); + } + + if (IsPackagePaymentAlreadyCompleted(transaction, paymentTx)) + { + await dbTx.CommitAsync(ct); + return BuildVerifyPackagePurchaseResponse( + purchase, transaction, paymentTx, alreadyPaid: true); + } + + if (transaction == null) + throw new RpcException(new Status(StatusCode.Internal, "تراکنش سفارش یافت نشد")); + + // Wallet history is the idempotency key for financial side-effects + var alreadyCredited = await _context.UserWalletHistories + .AnyAsync(h => h.RefrenceId == transaction.Id && !h.IsDeleted, ct); + + if (alreadyCredited) + { + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success; + transaction.PaymentDate ??= DateTime.UtcNow; + if (paymentTx != null) + { + paymentTx.PaymentStatus = true; + paymentTx.VerificationStatusCode = verifyResult.VerificationCode; + paymentTx.VerificationStatusMessage = verifyResult.Message; + paymentTx.RefId = verifyResult.TrackingCode; + } + await _context.SaveChangesAsync(ct); + await dbTx.CommitAsync(ct); + return BuildVerifyPackagePurchaseResponse( + purchase, transaction, paymentTx, alreadyPaid: true); + } + + if (paymentTx != null) + { + paymentTx.PaymentStatus = true; + paymentTx.VerificationStatusCode = verifyResult.VerificationCode; + paymentTx.VerificationStatusMessage = verifyResult.Message; + paymentTx.CardPan = verifyResult.CardPan; + paymentTx.CardHash = verifyResult.CardHash; + paymentTx.RefId = verifyResult.TrackingCode; + } + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success; transaction.PaymentDate = DateTime.UtcNow; transaction.RefId = verifyResult.RefId; - // شارژ کیف پول کاربر var wallet = await _context.UserWallets - .FirstOrDefaultAsync(w => w.UserId == purchase.UserId, context.CancellationToken); + .FirstOrDefaultAsync(w => w.UserId == purchase.UserId, ct); if (wallet == null) { @@ -313,7 +439,7 @@ public class PackageService : PackageContract.PackageContractBase NetworkBalance = 0 }; _context.UserWallets.Add(wallet); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(ct); } var discountMultiplier = purchase.Package?.DiscountMultiplier @@ -322,8 +448,7 @@ public class PackageService : PackageContract.PackageContractBase wallet.Balance += purchase.Amount; wallet.DiscountBalance += discountAmount; - // ثبت لاگ کیف پول - var walletLog = new CMSMicroservice.Domain.Entities.UserWalletHistory + _context.UserWalletHistories.Add(new CMSMicroservice.Domain.Entities.UserWalletHistory { WalletId = wallet.Id, CurrentBalance = wallet.Balance, @@ -335,23 +460,22 @@ public class PackageService : PackageContract.PackageContractBase IsIncrease = true, RefrenceId = transaction.Id, PackageId = purchase.PackageId - }; - _context.UserWalletHistories.Add(walletLog); + }); - // به‌روزرسانی کاربر var user = await _context.Users - .FirstOrDefaultAsync(u => u.Id == purchase.UserId, context.CancellationToken); + .FirstOrDefaultAsync(u => u.Id == purchase.UserId, ct); if (user != null) user.PackagePurchaseMethod = Domain.Enums.PackagePurchaseMethod.DirectPurchase; + + await _context.SaveChangesAsync(ct); + await dbTx.CommitAsync(ct); } - else if (transaction != null) + catch { - transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; + await dbTx.RollbackAsync(ct); + throw; } - - await _context.SaveChangesAsync(context.CancellationToken); - - // فعالسازی خودکار باشگاه مشتریان بعد از تأیید پرداخت موفق + if (verifyResult.IsSuccess) { try @@ -360,31 +484,54 @@ public class PackageService : PackageContract.PackageContractBase { UserId = purchase.UserId, ForceActivation = false - }, context.CancellationToken); + }, ct); } catch (Exception ex) { - // لاگ خطا ولی پرداخت موفق بوده — کاربر می‌تونه دستی فعالسازی کنه System.Console.WriteLine($"Auto club activation failed for UserId {purchase.UserId}: {ex.Message}"); } } - + + return BuildVerifyPackagePurchaseResponse( + purchase, transaction, paymentTx, alreadyPaid: false, verifyResult.RefId); + } + + private static bool IsPackagePaymentAlreadyCompleted( + CMSMicroservice.Domain.Entities.Transaction? transaction, + PaymentTransaction? paymentTx) + { + return transaction?.PaymentStatus == Domain.Enums.PaymentStatus.Success + || paymentTx?.PaymentStatus == true; + } + + private static CustomerVerifyPackagePurchaseResponse BuildVerifyPackagePurchaseResponse( + CMSMicroservice.Domain.Entities.UserPackagePurchase purchase, + CMSMicroservice.Domain.Entities.Transaction? transaction, + PaymentTransaction? paymentTx, + bool alreadyPaid, + string? referenceCode = null) + { + var refCode = referenceCode + ?? paymentTx?.RefId + ?? transaction?.RefId + ?? string.Empty; + return new CustomerVerifyPackagePurchaseResponse { - Success = verifyResult.IsSuccess, - Message = verifyResult.IsSuccess ? "خرید پکیج با موفقیت تایید شد" : (verifyResult.Message ?? "خرید پکیج ناموفق بود"), + Success = true, + Message = alreadyPaid ? "پرداخت قبلاً تأیید شده بود" : "خرید پکیج با موفقیت تایید شد", TransactionId = transaction?.Id ?? 0, - ReferenceCode = verifyResult.RefId ?? string.Empty, - PurchaseInfo = verifyResult.IsSuccess ? new PackagePurchaseInfo + ReferenceCode = refCode, + PurchaseInfo = new PackagePurchaseInfo { PackageId = purchase.PackageId, PackageName = purchase.Package?.Title ?? string.Empty, AmountPaid = purchase.Amount, PurchaseDate = Timestamp.FromDateTime(DateTime.SpecifyKind(purchase.PurchasedAt, DateTimeKind.Utc)) - } : null + } }; } - + private long GetCurrentUserId() { if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0) diff --git a/src/CMSMicroservice.WebApi/Services/TransactionsService.cs b/src/CMSMicroservice.WebApi/Services/TransactionsService.cs index dfd2d25..c88b48b 100644 --- a/src/CMSMicroservice.WebApi/Services/TransactionsService.cs +++ b/src/CMSMicroservice.WebApi/Services/TransactionsService.cs @@ -10,6 +10,8 @@ using CMSMicroservice.Application.TransactionsCQ.Commands.RefundTransaction; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransaction; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransactionsByFilter; using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Application.Common; +using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Domain.Entities.Payment; using AppModels = CMSMicroservice.Application.Common.Models; using Grpc.Core; @@ -28,6 +30,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase private readonly ICurrentUserService _currentUserService; private readonly IPaymentGatewayService _paymentGateway; private readonly IConfiguration _configuration; + private readonly IUserPaymentLock _paymentLock; public TransactionsService( IDispatchRequestToCQRS dispatchRequestToCQRS, @@ -35,7 +38,8 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase IApplicationDbContext context, ICurrentUserService currentUserService, IPaymentGatewayService paymentGateway, - IConfiguration configuration) + IConfiguration configuration, + IUserPaymentLock paymentLock) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; @@ -43,6 +47,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase _currentUserService = currentUserService; _paymentGateway = paymentGateway; _configuration = configuration; + _paymentLock = paymentLock; } public override async Task CreateNewTransactions(CreateNewTransactionsRequest request, ServerCallContext context) { @@ -142,13 +147,32 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase public override async Task CustomerPaymentRequest(CustomerPaymentRequestRequest request, ServerCallContext context) { var userId = GetCurrentUserId(); - + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(userId), + PaymentLockStrategy.FailFast, + ct => CustomerPaymentRequestCore(request, userId, ct), + context.CancellationToken); + } + catch (PaymentInProgressException ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); + } + } + + private async Task CustomerPaymentRequestCore( + CustomerPaymentRequestRequest request, + long userId, + CancellationToken cancellationToken) + { // Get user mobile for payment gateway var user = await _context.Users .AsNoTracking() .Where(u => u.Id == userId) .Select(u => new { u.Mobile, u.Email }) - .FirstOrDefaultAsync(context.CancellationToken); + .FirstOrDefaultAsync(cancellationToken); // Create transaction record in DB var transaction = new CMSMicroservice.Domain.Entities.Transaction @@ -160,7 +184,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase }; _context.Transactions.Add(transaction); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); // Callback URL از config — نه از ورودی کاربر (امنیت) var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268"; @@ -174,13 +198,13 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase Mobile = request.Mobile ?? user?.Mobile ?? string.Empty, Description = request.Description ?? "پرداخت آنلاین", CallbackUrl = callbackUrl - }, context.CancellationToken); + }, cancellationToken); if (!paymentResult.IsSuccess) { // Update transaction status to failed transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); throw new RpcException(new Status(StatusCode.Internal, paymentResult.ErrorMessage ?? "خطا در ارتباط با درگاه پرداخت")); @@ -206,7 +230,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase TransactionId = transaction.Id }; _context.PaymentTransactions.Add(paymentTx); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPaymentRequestResponse { @@ -215,11 +239,35 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase } public override async Task CustomerPaymentVerification(CustomerPaymentVerificationRequest request, ServerCallContext context) + { + var ct = context.CancellationToken; + var userId = GetCurrentUserId(); + var verifyKey = !string.IsNullOrEmpty(request.Authority) + ? request.Authority + : userId.ToString(); + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Verify(userId, verifyKey), + PaymentLockStrategy.WaitForRelease, + lockCt => CustomerPaymentVerificationCore(request, lockCt), + ct); + } + catch (PaymentInProgressException ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); + } + } + + private async Task CustomerPaymentVerificationCore( + CustomerPaymentVerificationRequest request, + CancellationToken cancellationToken) { // Find the transaction by authority/refId var transaction = await _context.Transactions .Where(t => t.RefId == request.Authority && !t.IsDeleted) - .FirstOrDefaultAsync(context.CancellationToken); + .FirstOrDefaultAsync(cancellationToken); if (transaction == null) throw new RpcException(new Status(StatusCode.NotFound, "تراکنش یافت نشد")); @@ -228,7 +276,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase if (request.Status != "OK") { transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPaymentVerificationResponse { @@ -241,13 +289,13 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase // واکشی PaymentTransaction برای گرفتن مبلغ (تومان) جهت verify var paymentTx = await _context.PaymentTransactions - .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken); + .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken); var amountInToman = paymentTx?.Amount ?? transaction.Amount; // Verify with gateway (مبلغ به تومان — سرویس زرین‌پال خودش ×۱۰ می‌کنه) var verifyResult = await _paymentGateway.VerifyPaymentAsync( - request.Authority, request.Status, (decimal)amountInToman, context.CancellationToken); + request.Authority, request.Status, (decimal)amountInToman, cancellationToken); if (paymentTx != null) { paymentTx.PaymentStatus = verifyResult.IsSuccess; @@ -269,7 +317,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; } - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPaymentVerificationResponse { diff --git a/src/CMSMicroservice.WebApi/Services/UserWalletService.cs b/src/CMSMicroservice.WebApi/Services/UserWalletService.cs index 3f0e015..3863aa5 100644 --- a/src/CMSMicroservice.WebApi/Services/UserWalletService.cs +++ b/src/CMSMicroservice.WebApi/Services/UserWalletService.cs @@ -13,6 +13,7 @@ using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWalletHistory; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings; using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Enums; using Grpc.Core; @@ -184,18 +185,29 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase { var userId = GetCurrentUserId(); - var result = await _sender.Send(new ChargeMagicWalletCommand + try { - UserId = userId, - Amount = request.Amount - }, context.CancellationToken); + var result = await _sender.Send(new ChargeMagicWalletCommand + { + UserId = userId, + Amount = request.Amount + }, context.CancellationToken); - return new InitiateMagicChargeResponse + return new InitiateMagicChargeResponse + { + IsSuccess = result.IsSuccess, + GatewayUrl = result.GatewayUrl ?? "", + ErrorMessage = result.ErrorMessage ?? "" + }; + } + catch (PaymentInProgressException ex) { - IsSuccess = result.IsSuccess, - GatewayUrl = result.GatewayUrl ?? "", - ErrorMessage = result.ErrorMessage ?? "" - }; + return new InitiateMagicChargeResponse + { + IsSuccess = false, + ErrorMessage = ex.Message + }; + } } // ============= Discount Wallet Methods ============= @@ -205,18 +217,29 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase { var userId = GetCurrentUserId(); - var result = await _sender.Send(new ChargeDiscountWalletCommand + try { - UserId = userId, - Amount = request.Amount - }, context.CancellationToken); + var result = await _sender.Send(new ChargeDiscountWalletCommand + { + UserId = userId, + Amount = request.Amount + }, context.CancellationToken); - return new InitiateDiscountChargeResponse + return new InitiateDiscountChargeResponse + { + IsSuccess = result.IsSuccess, + GatewayUrl = result.GatewayUrl ?? "", + ErrorMessage = result.ErrorMessage ?? "" + }; + } + catch (PaymentInProgressException ex) { - IsSuccess = result.IsSuccess, - GatewayUrl = result.GatewayUrl ?? "", - ErrorMessage = result.ErrorMessage ?? "" - }; + return new InitiateDiscountChargeResponse + { + IsSuccess = false, + ErrorMessage = ex.Message + }; + } } // ============= Wallet Verify Methods =============