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 45ce117..1b8bf84 100644
--- a/src/CMSMicroservice.WebApi/Services/PackageService.cs
+++ b/src/CMSMicroservice.WebApi/Services/PackageService.cs
@@ -10,6 +10,8 @@ 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;
@@ -32,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,
@@ -39,7 +42,8 @@ public class PackageService : PackageContract.PackageContractBase
IApplicationDbContext context,
ICurrentUserService currentUserService,
IPaymentGatewayService paymentGateway,
- IConfiguration configuration)
+ IConfiguration configuration,
+ IUserPaymentLock paymentLock)
{
_dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender;
@@ -47,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)
{
@@ -138,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, "پکیج مورد نظر یافت نشد"));
@@ -157,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
@@ -174,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";
@@ -194,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
{
@@ -229,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
{
@@ -245,6 +269,36 @@ public class PackageService : PackageContract.PackageContractBase
{
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)
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 =============