Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 15m25s
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:
@@ -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()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-2
@@ -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
|
||||||
|
|||||||
+15
-2
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
+15
-2
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
+15
-2
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
+26
-1
@@ -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);
|
|
||||||
|
try
|
||||||
var response = new PlaceOrderResponse
|
|
||||||
{
|
{
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -137,12 +143,31 @@ public class PackageService : PackageContract.PackageContractBase
|
|||||||
public override async Task<CustomerPurchasePackageResponse> CustomerPurchasePackage(CustomerPurchasePackageRequest request, ServerCallContext context)
|
public override async Task<CustomerPurchasePackageResponse> CustomerPurchasePackage(CustomerPurchasePackageRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
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,66 +267,167 @@ 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
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "پرداخت توسط کاربر لغو شد"
|
Message = "پرداخت توسط کاربر لغو شد"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// واکشی 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,31 +484,54 @@ 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
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private long GetCurrentUserId()
|
private long GetCurrentUserId()
|
||||||
{
|
{
|
||||||
if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0)
|
if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0)
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -142,13 +147,32 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
|
|||||||
public override async Task<CustomerPaymentRequestResponse> CustomerPaymentRequest(CustomerPaymentRequestRequest request, ServerCallContext context)
|
public override async Task<CustomerPaymentRequestResponse> CustomerPaymentRequest(CustomerPaymentRequestRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
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 =============
|
||||||
|
|||||||
Reference in New Issue
Block a user