feat: Implement Magic Wallet functionality
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m36s

- Added ClubMembershipCycle entity and DbSet to IApplicationDbContext.
- Updated VAT rate from 9% to 10% in VatCalculator and related areas.
- Introduced Magic Wallet settings in SystemConstants.
- Enhanced UserWallet entity to support Magic Wallet features.
- Updated TransactionType enum to include Magic Wallet transactions.
- Configured UserWallet to handle Magic Wallet properties in ApplicationDbContext.
- Implemented OrmCommissionCalculationStrategy to exclude Magic Wallet users from commission calculations.
- Added Protobuf definitions for Magic Wallet methods and responses.
- Created PaymentCallbackController endpoint for handling Magic Wallet charge callbacks.
- Updated UserOrderService to manage Magic Wallet state transitions.
- Developed UserWalletService to support Magic Wallet operations.
- Created ChargeMagicWalletCommand and its handler for initiating Magic Wallet charges.
- Implemented VerifyMagicWalletChargeCommand and handler for payment verification.
- Added validation for ChargeMagicWalletCommand.
- Established ClubMembershipCycle configuration for EF Core.
- Introduced WalletMode enum to differentiate between Normal and Magic modes.
This commit is contained in:
masoodafar-web
2026-02-21 19:50:28 +03:30
parent 04e8c49fa7
commit 2a569a024f
23 changed files with 914 additions and 22 deletions
@@ -0,0 +1,21 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
namespace CMSMicroservice.Application.WalletCQ.Commands.ChargeMagicWallet;
/// <summary>
/// شروع شارژ کیف‌پول جادویی از طریق درگاه پرداخت
/// مبلغ واریزی × 2.5 به Balance اعتبار داده می‌شود
/// </summary>
public class ChargeMagicWalletCommand : IRequest<PaymentInitiateResult>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; set; }
/// <summary>
/// مبلغ واریزی واقعی (ریال) — اعتبار = مبلغ × 2.5
/// </summary>
public long Amount { get; set; }
}
@@ -0,0 +1,174 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.WalletCQ.Commands.ChargeMagicWallet;
public class ChargeMagicWalletCommandHandler
: IRequestHandler<ChargeMagicWalletCommand, PaymentInitiateResult>
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration;
private readonly ILogger<ChargeMagicWalletCommandHandler> _logger;
public ChargeMagicWalletCommandHandler(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
IConfiguration configuration,
ILogger<ChargeMagicWalletCommandHandler> logger)
{
_context = context;
_paymentGateway = paymentGateway;
_configuration = configuration;
_logger = logger;
}
public async Task<PaymentInitiateResult> Handle(
ChargeMagicWalletCommand request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Initiating magic wallet charge for UserId: {UserId}, Amount: {Amount}",
request.UserId,
request.Amount
);
// 1. بررسی وجود کاربر
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User not found: {UserId}", request.UserId);
throw new NotFoundException(nameof(User), request.UserId);
}
// 2. بررسی وجود کیف‌پول
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken);
if (wallet == null)
{
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
throw new NotFoundException("کیف پول کاربر یافت نشد");
}
// 3. بررسی حالت جادویی
if (wallet.WalletMode != WalletMode.Magic)
{
_logger.LogWarning(
"User {UserId} wallet is not in Magic mode (current: {Mode})",
request.UserId,
wallet.WalletMode
);
throw new BadRequestException(
"شارژ جادویی فقط در حالت کیف‌پول جادویی امکان‌پذیر است"
);
}
// 4. بررسی سقف واریزی (per-cycle)
var remainingDeposit = SystemConstants.MagicWalletMaxDeposit - wallet.MagicTotalDeposited;
if (remainingDeposit <= 0)
{
_logger.LogWarning(
"User {UserId} has reached magic deposit cap. TotalDeposited: {TotalDeposited}",
request.UserId,
wallet.MagicTotalDeposited
);
throw new BadRequestException(
"سقف شارژ جادویی در این دور پر شده است"
);
}
if (request.Amount > remainingDeposit)
{
_logger.LogWarning(
"User {UserId} amount {Amount} exceeds remaining deposit cap {Remaining}",
request.UserId,
request.Amount,
remainingDeposit
);
throw new BadRequestException(
$"مبلغ واریزی بیش از سقف باقیمانده است. حداکثر مبلغ قابل واریز: {remainingDeposit:N0} ریال"
);
}
// 5. ایجاد درخواست پرداخت
var cmsBaseUrl = _configuration["CmsBaseUrl"] ?? "https://localhost:32846";
var callbackUrl = $"{cmsBaseUrl}/api/wallet/verify-magic-charge";
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 magic charge UserId {UserId}: {ErrorMessage}",
user.Id,
paymentResult.ErrorMessage
);
throw new Exception($"خطا در ارتباط با درگاه پرداخت: {paymentResult.ErrorMessage}");
}
// 6. ثبت PaymentTransaction
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(
"Magic wallet charge initiated. UserId: {UserId}, Amount: {Amount}, " +
"RemainingCap: {Remaining}, RefId: {RefId}, PaymentTxId: {PaymentTxId}",
user.Id,
request.Amount,
remainingDeposit - request.Amount,
paymentResult.RefId,
paymentTx.Id
);
return paymentResult;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error in ChargeMagicWalletCommand for UserId: {UserId}",
request.UserId
);
throw;
}
}
}
@@ -0,0 +1,19 @@
using FluentValidation;
namespace CMSMicroservice.Application.WalletCQ.Commands.ChargeMagicWallet;
public class ChargeMagicWalletCommandValidator : AbstractValidator<ChargeMagicWalletCommand>
{
public ChargeMagicWalletCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
RuleFor(x => x.Amount)
.GreaterThanOrEqualTo(100_000)
.WithMessage("حداقل مبلغ شارژ جادویی ۱۰,۰۰۰ تومان است")
.LessThanOrEqualTo(1_000_000_000)
.WithMessage("حداکثر مبلغ شارژ جادویی ۱۰۰,۰۰۰,۰۰۰ تومان است");
}
}
@@ -0,0 +1,19 @@
using MediatR;
namespace CMSMicroservice.Application.WalletCQ.Commands.VerifyMagicWalletCharge;
/// <summary>
/// تأیید شارژ کیف‌پول جادویی — بعد از بازگشت از درگاه پرداخت
/// </summary>
public class VerifyMagicWalletChargeCommand : IRequest<bool>
{
/// <summary>
/// کد Authority از درگاه
/// </summary>
public string Authority { get; set; } = string.Empty;
/// <summary>
/// وضعیت برگشتی از درگاه (OK / NOK)
/// </summary>
public string Status { get; set; } = string.Empty;
}
@@ -0,0 +1,209 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.WalletCQ.Commands.VerifyMagicWalletCharge;
public class VerifyMagicWalletChargeCommandHandler
: IRequestHandler<VerifyMagicWalletChargeCommand, bool>
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
private readonly ILogger<VerifyMagicWalletChargeCommandHandler> _logger;
public VerifyMagicWalletChargeCommandHandler(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
ILogger<VerifyMagicWalletChargeCommandHandler> logger)
{
_context = context;
_paymentGateway = paymentGateway;
_logger = logger;
}
public async Task<bool> Handle(
VerifyMagicWalletChargeCommand request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Verifying magic wallet charge. Authority: {Authority}, Status: {Status}",
request.Authority,
request.Status
);
// 1. پیدا کردن PaymentTransaction از Authority
var paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken);
if (paymentTx == null)
{
_logger.LogError("PaymentTransaction not found for Authority: {Authority}", request.Authority);
throw new NotFoundException("تراکنش پرداخت یافت نشد");
}
if (paymentTx.PaymentStatus)
{
_logger.LogWarning("PaymentTransaction already verified: {Authority}", request.Authority);
return true; // قبلاً تأیید شده
}
var userId = paymentTx.UserId
?? throw new BadRequestException("شناسه کاربر در تراکنش پرداخت یافت نشد");
var depositAmount = paymentTx.Amount; // مبلغ واقعی واریزی (ریال)
// 2. بررسی وضعیت برگشتی از درگاه
if (!string.Equals(request.Status, "OK", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Magic charge cancelled by user. UserId: {UserId}, Authority: {Authority}",
userId, request.Authority
);
paymentTx.PaymentStatus = false;
paymentTx.VerificationStatusMessage = "پرداخت توسط کاربر لغو شد";
await _context.SaveChangesAsync(cancellationToken);
throw new BadRequestException("پرداخت توسط کاربر لغو شد");
}
// 3. Verify با درگاه (زرین‌پال نیاز به مبلغ دارد)
var amountInToman = depositAmount / 10m;
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority,
request.Status,
amountInToman,
cancellationToken
);
// آپدیت PaymentTransaction
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(
"Magic wallet charge verification failed for UserId {UserId}: {Message}",
userId,
verifyResult.Message
);
await _context.SaveChangesAsync(cancellationToken);
throw new BadRequestException($"تراکنش ناموفق: {verifyResult.Message}");
}
// 4. بررسی کیف‌پول و حالت جادویی
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId, cancellationToken);
if (wallet == null)
{
_logger.LogError("Wallet not found for UserId: {UserId}", userId);
throw new NotFoundException("کیف پول کاربر یافت نشد");
}
if (wallet.WalletMode != WalletMode.Magic)
{
_logger.LogError(
"Wallet is not in Magic mode during verify. UserId: {UserId}, Mode: {Mode}",
userId, wallet.WalletMode
);
throw new BadRequestException("کیف‌پول در حالت جادویی نیست");
}
// 5. محاسبه اعتبار ×2.5
var creditAmount = (long)(depositAmount * SystemConstants.MagicWalletMultiplier); // مبلغ × 2.5
var bonusAmount = creditAmount - depositAmount; // بونوس = مبلغ × 1.5
// 6. ثبت تراکنش واریز واقعی (MagicWalletDeposit)
var depositTransaction = new Transaction
{
Amount = depositAmount,
Description = $"شارژ کیف‌پول جادویی - واریز واقعی - کاربر {userId}",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.UtcNow,
RefId = verifyResult.RefId,
Type = TransactionType.MagicWalletDeposit
};
_context.Transactions.Add(depositTransaction);
await _context.SaveChangesAsync(cancellationToken);
// 7. ثبت تراکنش بونوس (MagicWalletBonus)
var bonusTransaction = new Transaction
{
Amount = bonusAmount,
Description = $"شارژ کیف‌پول جادویی - بونوس ×{SystemConstants.MagicWalletMultiplier - 1} - کاربر {userId}",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.UtcNow,
RefId = $"MAGIC_BONUS_{depositTransaction.Id}",
Type = TransactionType.MagicWalletBonus
};
_context.Transactions.Add(bonusTransaction);
await _context.SaveChangesAsync(cancellationToken);
// 8. شارژ Balance و آپدیت شمارنده‌ها
wallet.Balance += creditAmount;
wallet.MagicTotalDeposited += depositAmount;
wallet.MagicTotalCredited += creditAmount;
// 9. ثبت WalletChangeLog (اجباری)
var walletLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = creditAmount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = 0,
IsIncrease = true,
RefrenceId = depositTransaction.Id
};
_context.UserWalletChangeLogs.Add(walletLog);
// 10. لینک PaymentTransaction به Transaction داخلی
paymentTx.TransactionId = depositTransaction.Id;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Magic wallet charged successfully. UserId: {UserId}, " +
"Deposit: {Deposit}, Credit: {Credit} (×{Multiplier}), Bonus: {Bonus}, " +
"TotalDeposited: {TotalDeposited}/{MaxDeposit}, NewBalance: {NewBalance}",
userId,
depositAmount,
creditAmount,
SystemConstants.MagicWalletMultiplier,
bonusAmount,
wallet.MagicTotalDeposited,
SystemConstants.MagicWalletMaxDeposit,
wallet.Balance
);
return true;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error in VerifyMagicWalletChargeCommand. Authority: {Authority}",
request.Authority
);
throw;
}
}
}