feat(wallet): add credit wallet functionality and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m49s

- Introduced new CreditWalletCharge enum value to TransactionType for clarity.
- Implemented InitiateCreditCharge and VerifyCreditCharge methods in UserWalletService for handling credit wallet transactions.
- Updated ZarinpalReconciliationJob to support credit wallet payment verification.
- Enhanced Protobuf definitions with new messages and RPC methods for credit wallet operations.
- Bumped Protobuf project version to reflect the addition of new features.
This commit is contained in:
masoodafar-web
2026-06-26 16:11:18 +03:30
parent 7570c39e65
commit 6c3fbe43b1
10 changed files with 462 additions and 1 deletions
@@ -0,0 +1,13 @@
using CMSMicroservice.Application.Common.Models;
using MediatR;
namespace CMSMicroservice.Application.WalletCQ.Commands.ChargeCreditWallet;
/// <summary>
/// دستور شارژ کیف پول اصلی از طریق درگاه
/// </summary>
public class ChargeCreditWalletCommand : IRequest<PaymentInitiateResult>
{
public long UserId { get; set; }
public long Amount { get; set; }
}
@@ -0,0 +1,123 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Payment;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.WalletCQ.Commands.ChargeCreditWallet;
public class ChargeCreditWalletCommandHandler
: IRequestHandler<ChargeCreditWalletCommand, PaymentInitiateResult>
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration;
private readonly ILogger<ChargeCreditWalletCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public ChargeCreditWalletCommandHandler(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
IConfiguration configuration,
ILogger<ChargeCreditWalletCommandHandler> logger,
IUserPaymentLock paymentLock)
{
_context = context;
_paymentGateway = paymentGateway;
_configuration = configuration;
_logger = logger;
_paymentLock = paymentLock;
}
public Task<PaymentInitiateResult> Handle(
ChargeCreditWalletCommand request,
CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Initiate(request.UserId),
PaymentLockStrategy.FailFast,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<PaymentInitiateResult> HandleCore(
ChargeCreditWalletCommand request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Charging credit wallet for UserId: {UserId}, Amount: {Amount}",
request.UserId,
request.Amount);
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken)
?? throw new NotFoundException(nameof(User), request.UserId);
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken)
?? throw new NotFoundException("کیف پول کاربر یافت نشد");
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268";
var callbackUrl = $"{frontOfficeBaseUrl}/profile/payment-callback?type=credit-wallet";
var paymentRequest = new PaymentRequest
{
Amount = request.Amount,
UserId = user.Id,
Mobile = user.Mobile ?? "",
CallbackUrl = callbackUrl,
Description = $"شارژ کیف پول اصلی - کاربر {user.Id}"
};
var paymentResult = await _paymentGateway.InitiatePaymentAsync(paymentRequest);
if (!paymentResult.IsSuccess)
{
_logger.LogError(
"Payment gateway failed for UserId {UserId}: {ErrorMessage}",
user.Id,
paymentResult.ErrorMessage);
throw new Exception($"خطا در ارتباط با درگاه پرداخت: {paymentResult.ErrorMessage}");
}
var paymentTx = new PaymentTransaction
{
GatewayProvider = _configuration["PaymentProvider"] ?? "zarinpal",
MerchantId = _configuration["ZarinPal:MerchantId"] ?? "",
Amount = request.Amount,
CallbackUrl = callbackUrl,
Description = $"شارژ کیف پول اصلی - کاربر {user.Id}",
Mobile = user.Mobile,
UserId = user.Id,
RequestStatusCode = 100,
RequestStatusMessage = "Success",
Authority = paymentResult.RefId,
PaymentStatus = false
};
_context.PaymentTransactions.Add(paymentTx);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Credit wallet charge initiated. UserId: {UserId}, RefId: {RefId}, PaymentTxId: {PaymentTxId}",
user.Id,
paymentResult.RefId,
paymentTx.Id);
return paymentResult;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error in ChargeCreditWalletCommand for UserId: {UserId}",
request.UserId);
throw;
}
}
}
@@ -0,0 +1,20 @@
using CMSMicroservice.Domain.Common;
using FluentValidation;
namespace CMSMicroservice.Application.WalletCQ.Commands.ChargeCreditWallet;
public class ChargeCreditWalletCommandValidator : AbstractValidator<ChargeCreditWalletCommand>
{
public ChargeCreditWalletCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
RuleFor(x => x.Amount)
.GreaterThanOrEqualTo(SystemConstants.DiscountWalletMinCharge)
.WithMessage("حداقل مبلغ شارژ ۱۰,۰۰۰ تومان است")
.LessThanOrEqualTo(SystemConstants.WalletMaxSafeAmount)
.WithMessage("مبلغ وارد شده بیش از حد مجاز است");
}
}
@@ -0,0 +1,13 @@
using MediatR;
namespace CMSMicroservice.Application.WalletCQ.Commands.VerifyCreditWalletCharge;
/// <summary>
/// دستور تأیید شارژ کیف پول اصلی
/// </summary>
public class VerifyCreditWalletChargeCommand : IRequest<bool>
{
public long UserId { get; set; }
public long Amount { get; set; }
public string Authority { get; set; } = string.Empty;
}
@@ -0,0 +1,157 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.WalletCQ.Commands.VerifyCreditWalletCharge;
public class VerifyCreditWalletChargeCommandHandler
: IRequestHandler<VerifyCreditWalletChargeCommand, bool>
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
private readonly ILogger<VerifyCreditWalletChargeCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public VerifyCreditWalletChargeCommandHandler(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
ILogger<VerifyCreditWalletChargeCommandHandler> logger,
IUserPaymentLock paymentLock)
{
_context = context;
_paymentGateway = paymentGateway;
_logger = logger;
_paymentLock = paymentLock;
}
public Task<bool> Handle(
VerifyCreditWalletChargeCommand request,
CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Verify(request.UserId, request.Authority),
PaymentLockStrategy.WaitForRelease,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<bool> HandleCore(
VerifyCreditWalletChargeCommand request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Verifying credit wallet charge. UserId: {UserId}, Amount: {Amount}, Authority: {Authority}",
request.UserId,
request.Amount,
request.Authority);
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken)
?? throw new NotFoundException(nameof(User), request.UserId);
var paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken);
if (paymentTx?.PaymentStatus == true)
{
_logger.LogWarning("PaymentTransaction already verified: {Authority}", request.Authority);
return true;
}
var amountInToman = (decimal)(paymentTx?.Amount ?? 0);
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority,
"OK",
amountInToman,
cancellationToken);
if (paymentTx != null)
{
paymentTx.PaymentStatus = verifyResult.IsSuccess;
paymentTx.VerificationStatusCode = verifyResult.VerificationCode;
paymentTx.VerificationStatusMessage = verifyResult.Message;
paymentTx.CardPan = verifyResult.CardPan;
paymentTx.CardHash = verifyResult.CardHash;
paymentTx.RefId = verifyResult.TrackingCode;
}
if (!verifyResult.IsSuccess)
{
_logger.LogWarning(
"Credit wallet charge verification failed for UserId {UserId}: {Message}",
request.UserId,
verifyResult.Message);
throw new Exception($"تراکنش ناموفق: {verifyResult.Message}");
}
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken)
?? throw new NotFoundException($"کیف پول کاربر با شناسه {request.UserId} یافت نشد");
var oldBalance = wallet.Balance;
wallet.Balance += request.Amount;
_logger.LogInformation(
"Charging credit balance for UserId {UserId}: {OldBalance} -> {NewBalance}",
request.UserId,
oldBalance,
wallet.Balance);
var transaction = new Transaction
{
Amount = request.Amount,
Description = $"شارژ کیف پول اصلی - کاربر {user.Id}",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
RefId = verifyResult.RefId,
Type = TransactionType.CreditWalletCharge
};
_context.Transactions.Add(transaction);
await _context.SaveChangesAsync(cancellationToken);
_context.UserWalletHistories.Add(new Domain.Entities.UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = request.Amount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = 0,
IsIncrease = true,
RefrenceId = transaction.Id
});
await _context.SaveChangesAsync(cancellationToken);
if (paymentTx != null)
{
paymentTx.TransactionId = transaction.Id;
await _context.SaveChangesAsync(cancellationToken);
}
_logger.LogInformation(
"Credit wallet charged successfully. UserId: {UserId}, TransactionId: {TransactionId}, RefId: {RefId}",
user.Id,
transaction.Id,
verifyResult.RefId);
return true;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error in VerifyCreditWalletChargeCommand for UserId: {UserId}",
request.UserId);
throw;
}
}
}