Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 15m25s

Deploy payment idempotency fixes and per-user payment locks to production.
This commit is contained in:
masoodafar-web
2026-06-08 02:00:30 +03:30
14 changed files with 688 additions and 118 deletions
@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.Common.Exceptions;
/// <summary>
/// Thrown when a concurrent payment operation is already in progress for the same scope.
/// </summary>
public class PaymentInProgressException : Exception
{
public PaymentInProgressException()
: base("یک عملیات پرداخت دیگر در حال پردازش است. لطفاً چند لحظه صبر کنید.")
{
}
public PaymentInProgressException(string message)
: base(message)
{
}
public PaymentInProgressException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -0,0 +1,32 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Per-scope in-process mutex for payment flows (initiate / verify).
/// Prevents the same user from running duplicate gateway operations concurrently.
/// </summary>
public interface IUserPaymentLock
{
Task<T> ExecuteAsync<T>(
string scope,
PaymentLockStrategy strategy,
Func<CancellationToken, Task<T>> action,
CancellationToken cancellationToken = default);
Task ExecuteAsync(
string scope,
PaymentLockStrategy strategy,
Func<CancellationToken, Task> action,
CancellationToken cancellationToken = default);
}
/// <summary>
/// How to behave when the payment lock is already held.
/// </summary>
public enum PaymentLockStrategy
{
/// <summary>Wait up to the configured timeout (callback / verify paths).</summary>
WaitForRelease,
/// <summary>Reject immediately (initiate / double-click paths).</summary>
FailFast
}
@@ -0,0 +1,19 @@
namespace CMSMicroservice.Application.Common;
/// <summary>
/// Canonical scope keys for <see cref="Interfaces.IUserPaymentLock"/>.
/// </summary>
public static class PaymentLockScopes
{
/// <summary>One active payment initiation per user (package, wallet charge, order, IPG deposit).</summary>
public static string Initiate(long userId) => $"payment:initiate:user:{userId}";
/// <summary>One active verify per user + authority/order (duplicate callback protection).</summary>
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()}";
}
}
@@ -1,3 +1,5 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Services; using CMSMicroservice.Application.Common.Services;
using CMSMicroservice.Domain.Entities.DiscountShop; using CMSMicroservice.Domain.Entities.DiscountShop;
@@ -17,22 +19,32 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<PlaceOrderCommandHandler> _logger; private readonly ILogger<PlaceOrderCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public PlaceOrderCommandHandler( public PlaceOrderCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IInventoryService inventoryService, IInventoryService inventoryService,
IPaymentGatewayService paymentGateway, IPaymentGatewayService paymentGateway,
IConfiguration configuration, IConfiguration configuration,
ILogger<PlaceOrderCommandHandler> logger) ILogger<PlaceOrderCommandHandler> logger,
IUserPaymentLock paymentLock)
{ {
_context = context; _context = context;
_inventoryService = inventoryService; _inventoryService = inventoryService;
_paymentGateway = paymentGateway; _paymentGateway = paymentGateway;
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
_paymentLock = paymentLock;
} }
public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken) public Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Initiate(request.UserId),
PaymentLockStrategy.FailFast,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<PlaceOrderResponseDto> HandleCore(PlaceOrderCommand request, CancellationToken cancellationToken)
{ {
// Get user wallet // Get user wallet
var userWallet = await _context.UserWallets var userWallet = await _context.UserWallets
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models; using CMSMicroservice.Application.Common.Models;
@@ -17,20 +18,32 @@ public class ChargeDiscountWalletCommandHandler
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<ChargeDiscountWalletCommandHandler> _logger; private readonly ILogger<ChargeDiscountWalletCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public ChargeDiscountWalletCommandHandler( public ChargeDiscountWalletCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IPaymentGatewayService paymentGateway, IPaymentGatewayService paymentGateway,
IConfiguration configuration, IConfiguration configuration,
ILogger<ChargeDiscountWalletCommandHandler> logger) ILogger<ChargeDiscountWalletCommandHandler> logger,
IUserPaymentLock paymentLock)
{ {
_context = context; _context = context;
_paymentGateway = paymentGateway; _paymentGateway = paymentGateway;
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
_paymentLock = paymentLock;
} }
public async Task<PaymentInitiateResult> Handle( public Task<PaymentInitiateResult> Handle(
ChargeDiscountWalletCommand request,
CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Initiate(request.UserId),
PaymentLockStrategy.FailFast,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<PaymentInitiateResult> HandleCore(
ChargeDiscountWalletCommand request, ChargeDiscountWalletCommand request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Common;
@@ -18,20 +19,32 @@ public class ChargeMagicWalletCommandHandler
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<ChargeMagicWalletCommandHandler> _logger; private readonly ILogger<ChargeMagicWalletCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public ChargeMagicWalletCommandHandler( public ChargeMagicWalletCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IPaymentGatewayService paymentGateway, IPaymentGatewayService paymentGateway,
IConfiguration configuration, IConfiguration configuration,
ILogger<ChargeMagicWalletCommandHandler> logger) ILogger<ChargeMagicWalletCommandHandler> logger,
IUserPaymentLock paymentLock)
{ {
_context = context; _context = context;
_paymentGateway = paymentGateway; _paymentGateway = paymentGateway;
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
_paymentLock = paymentLock;
} }
public async Task<PaymentInitiateResult> Handle( public Task<PaymentInitiateResult> Handle(
ChargeMagicWalletCommand request,
CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Initiate(request.UserId),
PaymentLockStrategy.FailFast,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<PaymentInitiateResult> HandleCore(
ChargeMagicWalletCommand request, ChargeMagicWalletCommand request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models; using CMSMicroservice.Application.Common.Models;
@@ -15,18 +16,30 @@ public class VerifyDiscountWalletChargeCommandHandler
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayService _paymentGateway;
private readonly ILogger<VerifyDiscountWalletChargeCommandHandler> _logger; private readonly ILogger<VerifyDiscountWalletChargeCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public VerifyDiscountWalletChargeCommandHandler( public VerifyDiscountWalletChargeCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IPaymentGatewayService paymentGateway, IPaymentGatewayService paymentGateway,
ILogger<VerifyDiscountWalletChargeCommandHandler> logger) ILogger<VerifyDiscountWalletChargeCommandHandler> logger,
IUserPaymentLock paymentLock)
{ {
_context = context; _context = context;
_paymentGateway = paymentGateway; _paymentGateway = paymentGateway;
_logger = logger; _logger = logger;
_paymentLock = paymentLock;
} }
public async Task<bool> Handle( public Task<bool> Handle(
VerifyDiscountWalletChargeCommand request,
CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Verify(request.UserId, request.Authority),
PaymentLockStrategy.WaitForRelease,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<bool> HandleCore(
VerifyDiscountWalletChargeCommand request, VerifyDiscountWalletChargeCommand request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Common;
@@ -16,20 +17,44 @@ public class VerifyMagicWalletChargeCommandHandler
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayService _paymentGateway;
private readonly ILogger<VerifyMagicWalletChargeCommandHandler> _logger; private readonly ILogger<VerifyMagicWalletChargeCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public VerifyMagicWalletChargeCommandHandler( public VerifyMagicWalletChargeCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IPaymentGatewayService paymentGateway, IPaymentGatewayService paymentGateway,
ILogger<VerifyMagicWalletChargeCommandHandler> logger) ILogger<VerifyMagicWalletChargeCommandHandler> logger,
IUserPaymentLock paymentLock)
{ {
_context = context; _context = context;
_paymentGateway = paymentGateway; _paymentGateway = paymentGateway;
_logger = logger; _logger = logger;
_paymentLock = paymentLock;
} }
public async Task<bool> Handle( public async Task<bool> Handle(
VerifyMagicWalletChargeCommand request, VerifyMagicWalletChargeCommand request,
CancellationToken cancellationToken) 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<bool> HandleCore(
VerifyMagicWalletChargeCommand request,
CancellationToken cancellationToken)
{ {
try try
{ {
@@ -39,6 +39,7 @@ public static class ConfigureServices
services.AddScoped<IAlertService, AlertService>(); services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IUserNotificationService, UserNotificationService>(); services.AddScoped<IUserNotificationService, UserNotificationService>();
services.AddScoped<IKavenegarService, KavenegarService>(); services.AddScoped<IKavenegarService, KavenegarService>();
services.AddSingleton<IUserPaymentLock, UserPaymentLockService>();
// Local file manager — files are saved to wwwroot/uploads/ on CMS disk // Local file manager — files are saved to wwwroot/uploads/ on CMS disk
services.AddSingleton<CMSMicroservice.Application.Common.FileManager.IFileManager, LocalFileManager>(); services.AddSingleton<CMSMicroservice.Application.Common.FileManager.IFileManager, LocalFileManager>();
services.AddScoped<IPermissionService, PermissionService>(); services.AddScoped<IPermissionService, PermissionService>();
@@ -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;
/// <summary>
/// In-process keyed semaphore lock for payment operations.
/// Safe for multi-threaded gRPC/MediatR handlers within a single CMS instance.
/// </summary>
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<string, LockEntry> _entries = new();
private readonly ILogger<UserPaymentLockService> _logger;
private readonly Timer _cleanupTimer;
private int _disposed;
public UserPaymentLockService(ILogger<UserPaymentLockService> logger)
{
_logger = logger;
_cleanupTimer = new Timer(_ => CleanupStaleEntries(), null, CleanupInterval, CleanupInterval);
}
public Task<T> ExecuteAsync<T>(
string scope,
PaymentLockStrategy strategy,
Func<CancellationToken, Task<T>> 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<CancellationToken, Task> 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<T> ExecuteCoreAsync<T>(
string scope,
PaymentLockStrategy strategy,
Func<CancellationToken, Task<T>> 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;
}
}
@@ -1,5 +1,7 @@
using CMSMicroservice.Protobuf.Protos.DiscountOrder; using CMSMicroservice.Protobuf.Protos.DiscountOrder;
using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder; using CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder;
using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment; using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment;
@@ -26,6 +28,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayService _paymentGateway;
private readonly ILogger<DiscountOrderService> _logger; private readonly ILogger<DiscountOrderService> _logger;
private readonly IUserPaymentLock _paymentLock;
public DiscountOrderService( public DiscountOrderService(
IDispatchRequestToCQRS dispatchRequestToCQRS, IDispatchRequestToCQRS dispatchRequestToCQRS,
@@ -33,7 +36,8 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
ICurrentUserService currentUserService, ICurrentUserService currentUserService,
IApplicationDbContext context, IApplicationDbContext context,
IPaymentGatewayService paymentGateway, IPaymentGatewayService paymentGateway,
ILogger<DiscountOrderService> logger) ILogger<DiscountOrderService> logger,
IUserPaymentLock paymentLock)
{ {
_dispatchRequestToCQRS = dispatchRequestToCQRS; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender; _sender = sender;
@@ -41,6 +45,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
_context = context; _context = context;
_paymentGateway = paymentGateway; _paymentGateway = paymentGateway;
_logger = logger; _logger = logger;
_paymentLock = paymentLock;
} }
private long GetCurrentUserId() private long GetCurrentUserId()
@@ -58,20 +63,32 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
UserAddressId = request.UserAddressId, UserAddressId = request.UserAddressId,
DiscountBalanceToUse = request.DiscountBalanceToUse DiscountBalanceToUse = request.DiscountBalanceToUse
}; };
var result = await _sender.Send(command);
var response = new PlaceOrderResponse try
{ {
Success = result.Success, var result = await _sender.Send(command);
Message = result.Message ?? string.Empty,
OrderId = result.OrderId ?? 0,
GatewayAmount = result.GatewayAmountRequired,
};
if (!string.IsNullOrEmpty(result.PaymentUrl)) var response = new PlaceOrderResponse
response.PaymentUrl = result.PaymentUrl; {
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<CompleteOrderPaymentResponse> CompleteOrderPayment(CompleteOrderPaymentRequest request, ServerCallContext context) public override async Task<CompleteOrderPaymentResponse> CompleteOrderPayment(CompleteOrderPaymentRequest request, ServerCallContext context)
@@ -193,6 +210,48 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
public override async Task<CustomerVerifyDiscountOrderPaymentResponse> CustomerVerifyDiscountOrderPayment( public override async Task<CustomerVerifyDiscountOrderPaymentResponse> CustomerVerifyDiscountOrderPayment(
CustomerVerifyDiscountOrderPaymentRequest request, ServerCallContext context) 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<CustomerVerifyDiscountOrderPaymentResponse> CustomerVerifyDiscountOrderPaymentCore(
CustomerVerifyDiscountOrderPaymentRequest request,
CancellationToken cancellationToken)
{ {
_logger.LogInformation( _logger.LogInformation(
"CustomerVerifyDiscountOrderPayment called: OrderId={OrderId}, Authority={Authority}, Status={Status}", "CustomerVerifyDiscountOrderPayment called: OrderId={OrderId}, Authority={Authority}, Status={Status}",
@@ -203,7 +262,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
// پیدا کردن سفارش و تراکنش // پیدا کردن سفارش و تراکنش
var order = await _context.DiscountOrders var order = await _context.DiscountOrders
.Include(o => o.OrderDetails) .Include(o => o.OrderDetails)
.FirstOrDefaultAsync(o => o.Id == request.OrderId, context.CancellationToken); .FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);
if (order == null) if (order == null)
{ {
@@ -218,7 +277,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
var transaction = order.TransactionId.HasValue var transaction = order.TransactionId.HasValue
? await _context.Transactions.FirstOrDefaultAsync( ? await _context.Transactions.FirstOrDefaultAsync(
t => t.Id == order.TransactionId.Value, context.CancellationToken) t => t.Id == order.TransactionId.Value, cancellationToken)
: null; : null;
// تأیید پرداخت از درگاه // تأیید پرداخت از درگاه
@@ -233,14 +292,14 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
request.Authority, request.Authority,
request.Status, request.Status,
order.GatewayAmountPaid, order.GatewayAmountPaid,
context.CancellationToken); cancellationToken);
paymentSuccess = verifyResult.IsSuccess; paymentSuccess = verifyResult.IsSuccess;
refId = verifyResult.TrackingCode ?? verifyResult.RefId; refId = verifyResult.TrackingCode ?? verifyResult.RefId;
// آپدیت PaymentTransaction با نتیجه verify // آپدیت PaymentTransaction با نتیجه verify
var paymentTx = await _context.PaymentTransactions var paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken); .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken);
if (paymentTx != null) if (paymentTx != null)
{ {
paymentTx.PaymentStatus = verifyResult.IsSuccess; paymentTx.PaymentStatus = verifyResult.IsSuccess;
@@ -249,7 +308,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
paymentTx.CardPan = verifyResult.CardPan; paymentTx.CardPan = verifyResult.CardPan;
paymentTx.CardHash = verifyResult.CardHash; paymentTx.CardHash = verifyResult.CardHash;
paymentTx.RefId = verifyResult.TrackingCode; paymentTx.RefId = verifyResult.TrackingCode;
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
_logger.LogInformation( _logger.LogInformation(
@@ -268,7 +327,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
TransactionId = transaction?.Id ?? 0, TransactionId = transaction?.Id ?? 0,
PaymentSuccess = paymentSuccess, PaymentSuccess = paymentSuccess,
RefId = refId RefId = refId
}, context.CancellationToken); }, cancellationToken);
return new CustomerVerifyDiscountOrderPaymentResponse return new CustomerVerifyDiscountOrderPaymentResponse
{ {
@@ -10,11 +10,14 @@ using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackages;
using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackageDetails; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackageDetails;
using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPurchaseHistory; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPurchaseHistory;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Entities.Payment; using CMSMicroservice.Domain.Entities.Payment;
using AppModels = CMSMicroservice.Application.Common.Models; using AppModels = CMSMicroservice.Application.Common.Models;
using Grpc.Core; using Grpc.Core;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data;
using System.Linq; using System.Linq;
using CMSMicroservice.Protobuf.Protos; using CMSMicroservice.Protobuf.Protos;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -31,6 +34,7 @@ public class PackageService : PackageContract.PackageContractBase
private readonly ICurrentUserService _currentUserService; private readonly ICurrentUserService _currentUserService;
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IUserPaymentLock _paymentLock;
public PackageService( public PackageService(
IDispatchRequestToCQRS dispatchRequestToCQRS, IDispatchRequestToCQRS dispatchRequestToCQRS,
@@ -38,7 +42,8 @@ public class PackageService : PackageContract.PackageContractBase
IApplicationDbContext context, IApplicationDbContext context,
ICurrentUserService currentUserService, ICurrentUserService currentUserService,
IPaymentGatewayService paymentGateway, IPaymentGatewayService paymentGateway,
IConfiguration configuration) IConfiguration configuration,
IUserPaymentLock paymentLock)
{ {
_dispatchRequestToCQRS = dispatchRequestToCQRS; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender; _sender = sender;
@@ -46,6 +51,7 @@ public class PackageService : PackageContract.PackageContractBase
_currentUserService = currentUserService; _currentUserService = currentUserService;
_paymentGateway = paymentGateway; _paymentGateway = paymentGateway;
_configuration = configuration; _configuration = configuration;
_paymentLock = paymentLock;
} }
public override async Task<CreateNewPackageResponse> CreateNewPackage(CreateNewPackageRequest request, ServerCallContext context) public override async Task<CreateNewPackageResponse> CreateNewPackage(CreateNewPackageRequest request, ServerCallContext context)
{ {
@@ -138,11 +144,30 @@ public class PackageService : PackageContract.PackageContractBase
{ {
var userId = GetCurrentUserId(); 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<CustomerPurchasePackageResponse> CustomerPurchasePackageCore(
CustomerPurchasePackageRequest request,
long userId,
CancellationToken cancellationToken)
{
// Lookup package // Lookup package
var package = await _context.Packages var package = await _context.Packages
.AsNoTracking() .AsNoTracking()
.Where(p => p.Id == request.PackageId && !p.IsDeleted) .Where(p => p.Id == request.PackageId && !p.IsDeleted)
.FirstOrDefaultAsync(context.CancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (package == null) if (package == null)
throw new RpcException(new Status(StatusCode.NotFound, "پکیج مورد نظر یافت نشد")); throw new RpcException(new Status(StatusCode.NotFound, "پکیج مورد نظر یافت نشد"));
@@ -156,7 +181,7 @@ public class PackageService : PackageContract.PackageContractBase
Type = Domain.Enums.TransactionType.Buy Type = Domain.Enums.TransactionType.Buy
}; };
_context.Transactions.Add(transaction); _context.Transactions.Add(transaction);
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
// Create purchase record // Create purchase record
var purchaseMethod = request.PurchaseMethod == PurchaseMethodEnum.PurchaseMethodGateway var purchaseMethod = request.PurchaseMethod == PurchaseMethodEnum.PurchaseMethodGateway
@@ -173,14 +198,14 @@ public class PackageService : PackageContract.PackageContractBase
TransactionId = transaction.Id TransactionId = transaction.Id
}; };
_context.UserPackagePurchases.Add(purchase); _context.UserPackagePurchases.Add(purchase);
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
// Initiate payment with gateway // Initiate payment with gateway
var user = await _context.Users var user = await _context.Users
.AsNoTracking() .AsNoTracking()
.Where(u => u.Id == userId) .Where(u => u.Id == userId)
.Select(u => new { u.Mobile }) .Select(u => new { u.Mobile })
.FirstOrDefaultAsync(context.CancellationToken); .FirstOrDefaultAsync(cancellationToken);
// Callback URL از config — نه از ورودی کاربر (امنیت) // Callback URL از config — نه از ورودی کاربر (امنیت)
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268"; var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268";
@@ -193,12 +218,12 @@ public class PackageService : PackageContract.PackageContractBase
Mobile = user?.Mobile ?? string.Empty, Mobile = user?.Mobile ?? string.Empty,
Description = $"خرید پکیج {package.Title}", Description = $"خرید پکیج {package.Title}",
CallbackUrl = callbackUrl CallbackUrl = callbackUrl
}, context.CancellationToken); }, cancellationToken);
if (!paymentResult.IsSuccess) if (!paymentResult.IsSuccess)
{ {
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
return new CustomerPurchasePackageResponse return new CustomerPurchasePackageResponse
{ {
@@ -228,7 +253,7 @@ public class PackageService : PackageContract.PackageContractBase
OrderId = purchase.Id.ToString() OrderId = purchase.Id.ToString()
}; };
_context.PaymentTransactions.Add(paymentTx); _context.PaymentTransactions.Add(paymentTx);
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
return new CustomerPurchasePackageResponse return new CustomerPurchasePackageResponse
{ {
@@ -242,29 +267,70 @@ public class PackageService : PackageContract.PackageContractBase
public override async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchase(CustomerVerifyPackagePurchaseRequest request, ServerCallContext context) public override async Task<CustomerVerifyPackagePurchaseResponse> 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<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchaseCore(
CustomerVerifyPackagePurchaseRequest request,
CancellationToken ct)
{
var purchase = await _context.UserPackagePurchases var purchase = await _context.UserPackagePurchases
.Include(p => p.Package) .Include(p => p.Package)
.Where(p => p.Id == request.OrderId && !p.IsDeleted) .Where(p => p.Id == request.OrderId && !p.IsDeleted)
.FirstOrDefaultAsync(context.CancellationToken); .FirstOrDefaultAsync(ct);
if (purchase == null) if (purchase == null)
throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد"));
// Find the associated transaction
var transaction = purchase.TransactionId.HasValue var transaction = purchase.TransactionId.HasValue
? await _context.Transactions ? await _context.Transactions
.Where(t => t.Id == purchase.TransactionId.Value) .Where(t => t.Id == purchase.TransactionId.Value)
.FirstOrDefaultAsync(context.CancellationToken) .FirstOrDefaultAsync(ct)
: null; : 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 (request.Status != "OK")
{ {
if (transaction != null) if (transaction != null && transaction.PaymentStatus != Domain.Enums.PaymentStatus.Success)
{ {
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(ct);
} }
return new CustomerVerifyPackagePurchaseResponse return new CustomerVerifyPackagePurchaseResponse
@@ -274,34 +340,94 @@ public class PackageService : PackageContract.PackageContractBase
}; };
} }
// واکشی PaymentTransaction برای گرفتن مبلغ (تومان) جهت verify
var paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken);
var amountInToman = paymentTx?.Amount ?? purchase.Amount; var amountInToman = paymentTx?.Amount ?? purchase.Amount;
// Verify with payment gateway (مبلغ به تومان — سرویس زرین‌پال خودش ×۱۰ می‌کنه)
var verifyResult = await _paymentGateway.VerifyPaymentAsync( var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority, request.Status, (decimal)amountInToman, context.CancellationToken); request.Authority, request.Status, (decimal)amountInToman, ct);
if (paymentTx != null)
if (!verifyResult.IsSuccess)
{ {
paymentTx.PaymentStatus = verifyResult.IsSuccess; if (paymentTx != null)
paymentTx.VerificationStatusCode = verifyResult.VerificationCode; {
paymentTx.VerificationStatusMessage = verifyResult.Message; paymentTx.PaymentStatus = false;
paymentTx.CardPan = verifyResult.CardPan; paymentTx.VerificationStatusCode = verifyResult.VerificationCode;
paymentTx.CardHash = verifyResult.CardHash; paymentTx.VerificationStatusMessage = verifyResult.Message;
paymentTx.RefId = verifyResult.TrackingCode; }
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.PaymentStatus = Domain.Enums.PaymentStatus.Success;
transaction.PaymentDate = DateTime.UtcNow; transaction.PaymentDate = DateTime.UtcNow;
transaction.RefId = verifyResult.RefId; transaction.RefId = verifyResult.RefId;
// شارژ کیف پول کاربر
var wallet = await _context.UserWallets var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == purchase.UserId, context.CancellationToken); .FirstOrDefaultAsync(w => w.UserId == purchase.UserId, ct);
if (wallet == null) if (wallet == null)
{ {
@@ -313,7 +439,7 @@ public class PackageService : PackageContract.PackageContractBase
NetworkBalance = 0 NetworkBalance = 0
}; };
_context.UserWallets.Add(wallet); _context.UserWallets.Add(wallet);
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(ct);
} }
var discountMultiplier = purchase.Package?.DiscountMultiplier var discountMultiplier = purchase.Package?.DiscountMultiplier
@@ -322,8 +448,7 @@ public class PackageService : PackageContract.PackageContractBase
wallet.Balance += purchase.Amount; wallet.Balance += purchase.Amount;
wallet.DiscountBalance += discountAmount; wallet.DiscountBalance += discountAmount;
// ثبت لاگ کیف پول _context.UserWalletHistories.Add(new CMSMicroservice.Domain.Entities.UserWalletHistory
var walletLog = new CMSMicroservice.Domain.Entities.UserWalletHistory
{ {
WalletId = wallet.Id, WalletId = wallet.Id,
CurrentBalance = wallet.Balance, CurrentBalance = wallet.Balance,
@@ -335,23 +460,22 @@ public class PackageService : PackageContract.PackageContractBase
IsIncrease = true, IsIncrease = true,
RefrenceId = transaction.Id, RefrenceId = transaction.Id,
PackageId = purchase.PackageId PackageId = purchase.PackageId
}; });
_context.UserWalletHistories.Add(walletLog);
// به‌روزرسانی کاربر
var user = await _context.Users var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == purchase.UserId, context.CancellationToken); .FirstOrDefaultAsync(u => u.Id == purchase.UserId, ct);
if (user != null) if (user != null)
user.PackagePurchaseMethod = Domain.Enums.PackagePurchaseMethod.DirectPurchase; 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) if (verifyResult.IsSuccess)
{ {
try try
@@ -360,28 +484,51 @@ public class PackageService : PackageContract.PackageContractBase
{ {
UserId = purchase.UserId, UserId = purchase.UserId,
ForceActivation = false ForceActivation = false
}, context.CancellationToken); }, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
// لاگ خطا ولی پرداخت موفق بوده — کاربر می‌تونه دستی فعالسازی کنه
System.Console.WriteLine($"Auto club activation failed for UserId {purchase.UserId}: {ex.Message}"); 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 return new CustomerVerifyPackagePurchaseResponse
{ {
Success = verifyResult.IsSuccess, Success = true,
Message = verifyResult.IsSuccess ? "خرید پکیج با موفقیت تایید شد" : (verifyResult.Message ?? "خرید پکیج ناموفق بود"), Message = alreadyPaid ? "پرداخت قبلاً تأیید شده بود" : "خرید پکیج با موفقیت تایید شد",
TransactionId = transaction?.Id ?? 0, TransactionId = transaction?.Id ?? 0,
ReferenceCode = verifyResult.RefId ?? string.Empty, ReferenceCode = refCode,
PurchaseInfo = verifyResult.IsSuccess ? new PackagePurchaseInfo PurchaseInfo = new PackagePurchaseInfo
{ {
PackageId = purchase.PackageId, PackageId = purchase.PackageId,
PackageName = purchase.Package?.Title ?? string.Empty, PackageName = purchase.Package?.Title ?? string.Empty,
AmountPaid = purchase.Amount, AmountPaid = purchase.Amount,
PurchaseDate = Timestamp.FromDateTime(DateTime.SpecifyKind(purchase.PurchasedAt, DateTimeKind.Utc)) PurchaseDate = Timestamp.FromDateTime(DateTime.SpecifyKind(purchase.PurchasedAt, DateTimeKind.Utc))
} : null }
}; };
} }
@@ -10,6 +10,8 @@ using CMSMicroservice.Application.TransactionsCQ.Commands.RefundTransaction;
using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransaction; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransaction;
using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransactionsByFilter; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransactionsByFilter;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Entities.Payment; using CMSMicroservice.Domain.Entities.Payment;
using AppModels = CMSMicroservice.Application.Common.Models; using AppModels = CMSMicroservice.Application.Common.Models;
using Grpc.Core; using Grpc.Core;
@@ -28,6 +30,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
private readonly ICurrentUserService _currentUserService; private readonly ICurrentUserService _currentUserService;
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IUserPaymentLock _paymentLock;
public TransactionsService( public TransactionsService(
IDispatchRequestToCQRS dispatchRequestToCQRS, IDispatchRequestToCQRS dispatchRequestToCQRS,
@@ -35,7 +38,8 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
IApplicationDbContext context, IApplicationDbContext context,
ICurrentUserService currentUserService, ICurrentUserService currentUserService,
IPaymentGatewayService paymentGateway, IPaymentGatewayService paymentGateway,
IConfiguration configuration) IConfiguration configuration,
IUserPaymentLock paymentLock)
{ {
_dispatchRequestToCQRS = dispatchRequestToCQRS; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender; _sender = sender;
@@ -43,6 +47,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
_currentUserService = currentUserService; _currentUserService = currentUserService;
_paymentGateway = paymentGateway; _paymentGateway = paymentGateway;
_configuration = configuration; _configuration = configuration;
_paymentLock = paymentLock;
} }
public override async Task<CreateNewTransactionsResponse> CreateNewTransactions(CreateNewTransactionsRequest request, ServerCallContext context) public override async Task<CreateNewTransactionsResponse> CreateNewTransactions(CreateNewTransactionsRequest request, ServerCallContext context)
{ {
@@ -143,12 +148,31 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
{ {
var userId = GetCurrentUserId(); 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<CustomerPaymentRequestResponse> CustomerPaymentRequestCore(
CustomerPaymentRequestRequest request,
long userId,
CancellationToken cancellationToken)
{
// Get user mobile for payment gateway // Get user mobile for payment gateway
var user = await _context.Users var user = await _context.Users
.AsNoTracking() .AsNoTracking()
.Where(u => u.Id == userId) .Where(u => u.Id == userId)
.Select(u => new { u.Mobile, u.Email }) .Select(u => new { u.Mobile, u.Email })
.FirstOrDefaultAsync(context.CancellationToken); .FirstOrDefaultAsync(cancellationToken);
// Create transaction record in DB // Create transaction record in DB
var transaction = new CMSMicroservice.Domain.Entities.Transaction var transaction = new CMSMicroservice.Domain.Entities.Transaction
@@ -160,7 +184,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
}; };
_context.Transactions.Add(transaction); _context.Transactions.Add(transaction);
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
// Callback URL از config — نه از ورودی کاربر (امنیت) // Callback URL از config — نه از ورودی کاربر (امنیت)
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268"; var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268";
@@ -174,13 +198,13 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
Mobile = request.Mobile ?? user?.Mobile ?? string.Empty, Mobile = request.Mobile ?? user?.Mobile ?? string.Empty,
Description = request.Description ?? "پرداخت آنلاین", Description = request.Description ?? "پرداخت آنلاین",
CallbackUrl = callbackUrl CallbackUrl = callbackUrl
}, context.CancellationToken); }, cancellationToken);
if (!paymentResult.IsSuccess) if (!paymentResult.IsSuccess)
{ {
// Update transaction status to failed // Update transaction status to failed
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
throw new RpcException(new Status(StatusCode.Internal, throw new RpcException(new Status(StatusCode.Internal,
paymentResult.ErrorMessage ?? "خطا در ارتباط با درگاه پرداخت")); paymentResult.ErrorMessage ?? "خطا در ارتباط با درگاه پرداخت"));
@@ -206,7 +230,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
TransactionId = transaction.Id TransactionId = transaction.Id
}; };
_context.PaymentTransactions.Add(paymentTx); _context.PaymentTransactions.Add(paymentTx);
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
return new CustomerPaymentRequestResponse return new CustomerPaymentRequestResponse
{ {
@@ -215,11 +239,35 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
} }
public override async Task<CustomerPaymentVerificationResponse> CustomerPaymentVerification(CustomerPaymentVerificationRequest request, ServerCallContext context) public override async Task<CustomerPaymentVerificationResponse> 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<CustomerPaymentVerificationResponse> CustomerPaymentVerificationCore(
CustomerPaymentVerificationRequest request,
CancellationToken cancellationToken)
{ {
// Find the transaction by authority/refId // Find the transaction by authority/refId
var transaction = await _context.Transactions var transaction = await _context.Transactions
.Where(t => t.RefId == request.Authority && !t.IsDeleted) .Where(t => t.RefId == request.Authority && !t.IsDeleted)
.FirstOrDefaultAsync(context.CancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (transaction == null) if (transaction == null)
throw new RpcException(new Status(StatusCode.NotFound, "تراکنش یافت نشد")); throw new RpcException(new Status(StatusCode.NotFound, "تراکنش یافت نشد"));
@@ -228,7 +276,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
if (request.Status != "OK") if (request.Status != "OK")
{ {
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
return new CustomerPaymentVerificationResponse return new CustomerPaymentVerificationResponse
{ {
@@ -241,13 +289,13 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
// واکشی PaymentTransaction برای گرفتن مبلغ (تومان) جهت verify // واکشی PaymentTransaction برای گرفتن مبلغ (تومان) جهت verify
var paymentTx = await _context.PaymentTransactions 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; var amountInToman = paymentTx?.Amount ?? transaction.Amount;
// Verify with gateway (مبلغ به تومان — سرویس زرین‌پال خودش ×۱۰ می‌کنه) // Verify with gateway (مبلغ به تومان — سرویس زرین‌پال خودش ×۱۰ می‌کنه)
var verifyResult = await _paymentGateway.VerifyPaymentAsync( var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority, request.Status, (decimal)amountInToman, context.CancellationToken); request.Authority, request.Status, (decimal)amountInToman, cancellationToken);
if (paymentTx != null) if (paymentTx != null)
{ {
paymentTx.PaymentStatus = verifyResult.IsSuccess; paymentTx.PaymentStatus = verifyResult.IsSuccess;
@@ -269,7 +317,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
} }
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(cancellationToken);
return new CustomerPaymentVerificationResponse return new CustomerPaymentVerificationResponse
{ {
@@ -13,6 +13,7 @@ using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWalletHistory;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Enums; using CMSMicroservice.Domain.Enums;
using Grpc.Core; using Grpc.Core;
@@ -184,18 +185,29 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
{ {
var userId = GetCurrentUserId(); var userId = GetCurrentUserId();
var result = await _sender.Send(new ChargeMagicWalletCommand try
{ {
UserId = userId, var result = await _sender.Send(new ChargeMagicWalletCommand
Amount = request.Amount {
}, context.CancellationToken); 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, return new InitiateMagicChargeResponse
GatewayUrl = result.GatewayUrl ?? "", {
ErrorMessage = result.ErrorMessage ?? "" IsSuccess = false,
}; ErrorMessage = ex.Message
};
}
} }
// ============= Discount Wallet Methods ============= // ============= Discount Wallet Methods =============
@@ -205,18 +217,29 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
{ {
var userId = GetCurrentUserId(); var userId = GetCurrentUserId();
var result = await _sender.Send(new ChargeDiscountWalletCommand try
{ {
UserId = userId, var result = await _sender.Send(new ChargeDiscountWalletCommand
Amount = request.Amount {
}, context.CancellationToken); 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, return new InitiateDiscountChargeResponse
GatewayUrl = result.GatewayUrl ?? "", {
ErrorMessage = result.ErrorMessage ?? "" IsSuccess = false,
}; ErrorMessage = ex.Message
};
}
} }
// ============= Wallet Verify Methods ============= // ============= Wallet Verify Methods =============