Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 8m56s

This commit is contained in:
masoodafar-web
2026-02-21 20:37:55 +03:30
26 changed files with 922 additions and 30 deletions
@@ -183,10 +183,9 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
return true;
}
// فعال‌سازی مجدد
// فعال‌سازی مجدد — ActivatedAt حفظ میشه (overwrite نمیشه)
entity = existingMembership;
entity.IsActive = true;
entity.ActivatedAt = activationDate;
entity.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(entity);
@@ -199,6 +198,41 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
await _context.SaveChangesAsync(cancellationToken);
// 6.5. ایجاد ClubMembershipCycle — هر خرید پکیج یک دور جدید
var previousCycles = await _context.ClubMembershipCycles
.Where(c => c.UserId == user.Id && c.IsCurrentCycle)
.ToListAsync(cancellationToken);
foreach (var prevCycle in previousCycles)
{
prevCycle.IsCurrentCycle = false;
}
var maxCycleNumber = await _context.ClubMembershipCycles
.Where(c => c.ClubMembershipId == entity.Id)
.MaxAsync(c => (int?)c.CycleNumber, cancellationToken) ?? 0;
var newCycle = new ClubMembershipCycle
{
UserId = user.Id,
ClubMembershipId = entity.Id,
CycleNumber = maxCycleNumber + 1,
PackagePurchasedAt = activationDate,
PurchaseMethod = user.PackagePurchaseMethod,
PackageAmount = SystemConstants.BasePackageAmount,
IsCurrentCycle = true
};
_context.ClubMembershipCycles.Add(newCycle);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Created ClubMembershipCycle #{CycleNumber} for UserId {UserId}, MembershipId {MembershipId}",
newCycle.CycleNumber,
user.Id,
entity.Id
);
// 7. ثبت تاریخچه
var history = new ClubMembershipHistory
{
@@ -43,8 +43,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
// ⭐ دریافت همه کاربرانی که عضو فعال باشگاه هستند
// بدون محدودیت زمانی - همه اعضای فعال کلاب باید کمیسیون بگیرند
// ⚠️ Magic Wallet: کاربرهایی که در حالت جادویی هستند از کمیسیون خارج میشن
var magicModeUserIds = await _context.UserWallets
.Where(w => w.WalletMode == WalletMode.Magic)
.Select(w => w.UserId)
.ToHashSetAsync(cancellationToken);
var activeClubMemberUserIds = await _context.ClubMemberships
.Where(c => c.IsActive)
.Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId))
.Select(c => c.UserId)
.ToHashSetAsync(cancellationToken);
@@ -256,11 +262,12 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var count = 0;
// اگر فرزند در این هفته فعال شده، 1 امتیاز
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
// اگر فرزند در این هفته پکیج خریده (Cycle جدید ساخته شده)، 1 امتیاز
// ⚠️ از ClubMembershipCycle.PackagePurchasedAt استفاده میکنیم (نه ActivatedAt که overwrite میشد)
var currentCycle = await _context.ClubMembershipCycles
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsCurrentCycle, cancellationToken);
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
if (currentCycle?.PackagePurchasedAt >= startDate && currentCycle?.PackagePurchasedAt <= endDate)
{
count = 1;
}
@@ -38,6 +38,7 @@ public interface IApplicationDbContext
DbSet<PublicMessage> PublicMessages { get; }
DbSet<ClubMembership> ClubMemberships { get; }
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
DbSet<ClubMembershipCycle> ClubMembershipCycles { get; }
DbSet<ClubFeature> ClubFeatures { get; }
DbSet<UserClubFeature> UserClubFeatures { get; }
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.Common.Services;
public static class VatCalculator
{
/// <summary>
/// نرخ VAT ایران - 9 درصد
/// نرخ VAT ایران - 10 درصد
/// </summary>
public const decimal VAT_RATE = 0.09m;
public const decimal VAT_RATE = 0.10m;
/// <summary>
/// نرخ VAT به صورت درصد (9)
/// نرخ VAT به صورت درصد (10)
/// </summary>
public const int VAT_PERCENT = 9;
public const int VAT_PERCENT = 10;
/// <summary>
/// محاسبه VAT از مبلغ خالص
@@ -133,7 +133,7 @@ public class VerifyBasePackagePaymentCommandHandler : IRequestHandler<VerifyBase
var oldDiscountBalance = userWallet.DiscountBalance;
userWallet.Balance += SystemConstants.BasePackageAmount;
userWallet.DiscountBalance += SystemConstants.BasePackageAmount;
userWallet.DiscountBalance += SystemConstants.BasePackageAmount * 2;
_logger.LogInformation(
"Charging wallet for user {UserId}. Balance: {OldBalance} -> {NewBalance}, DiscountBalance: {OldDiscount} -> {NewDiscount}",
@@ -157,7 +157,7 @@ public class VerifyBasePackagePaymentCommandHandler : IRequestHandler<VerifyBase
CurrentNetworkBalance = userWallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = userWallet.DiscountBalance,
ChangeDiscountValue = SystemConstants.BasePackageAmount,
ChangeDiscountValue = SystemConstants.BasePackageAmount * 2,
IsIncrease = true,
RefrenceId = transaction.Id
};
@@ -99,9 +99,9 @@ public class VerifyPackagePurchaseCommandHandler
wallet.Balance
);
// شارژ DiscountBalance (موجودی تخفیف)
// شارژ DiscountBalance (موجودی تخفیف) — دو برابر مبلغ سفارش
var oldDiscountBalance = wallet.DiscountBalance;
wallet.DiscountBalance += order.Amount;
wallet.DiscountBalance += order.Amount * 2;
_logger.LogInformation(
"Charging DiscountBalance for UserId {UserId}: {OldBalance} -> {NewBalance}",
@@ -132,7 +132,7 @@ public class VerifyPackagePurchaseCommandHandler
ChangeValue = order.Amount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance - order.Amount, // قبل از شارژ DiscountBalance
CurrentDiscountBalance = wallet.DiscountBalance - (order.Amount * 2), // قبل از شارژ DiscountBalance
ChangeDiscountValue = 0,
IsIncrease = true,
RefrenceId = transaction.Id
@@ -148,7 +148,7 @@ public class VerifyPackagePurchaseCommandHandler
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = order.Amount,
ChangeDiscountValue = order.Amount * 2,
IsIncrease = true,
RefrenceId = transaction.Id
};
@@ -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;
}
}
}
@@ -92,6 +92,25 @@ public static class SystemConstants
#endregion
#region Magic Wallet Settings
/// <summary>
/// ضریب شارژ کیف‌پول جادویی — واریز × 2.5 = اعتبار
/// </summary>
public const decimal MagicWalletMultiplier = 2.5m;
/// <summary>
/// سقف واریز در هر دور جادویی (ریال) — 100M تومان
/// </summary>
public const long MagicWalletMaxDeposit = 1_000_000_000;
/// <summary>
/// سقف اعتبار در هر دور جادویی (ریال) — 250M تومان
/// </summary>
public const long MagicWalletMaxCredit = 2_500_000_000;
#endregion
#region Shop Settings
/// <summary>
@@ -134,6 +153,11 @@ public static class SystemConstants
["System.MaintenanceMode"] = SystemMaintenanceMode,
["System.EnableAuditLog"] = SystemEnableAuditLog,
// Magic Wallet
["MagicWallet.Multiplier"] = MagicWalletMultiplier,
["MagicWallet.MaxDeposit"] = MagicWalletMaxDeposit,
["MagicWallet.MaxCredit"] = MagicWalletMaxCredit,
// Shop
["Shop.VAT"] = ShopVAT,
["Shop.VATEnabled"] = ShopVATEnabled
@@ -166,6 +190,11 @@ public static class SystemConstants
("System.MaintenanceMode", SystemMaintenanceMode, "حالت تعمیر و نگهداری سیستم"),
("System.EnableAuditLog", SystemEnableAuditLog, "فعال‌سازی لاگ تغییرات"),
// Magic Wallet
("MagicWallet.Multiplier", MagicWalletMultiplier, "ضریب شارژ کیف‌پول جادویی (×2.5)"),
("MagicWallet.MaxDeposit", MagicWalletMaxDeposit, "سقف واریز هر دور جادویی (ریال)"),
("MagicWallet.MaxCredit", MagicWalletMaxCredit, "سقف اعتبار هر دور جادویی (ریال)"),
// Shop
("Shop.VAT", ShopVAT, "مالیات بر ارزش افزوده"),
("Shop.VATEnabled", ShopVATEnabled, "مالیات فعال است؟")
@@ -55,4 +55,9 @@ public class ClubMembership : BaseAuditableEntity
/// ClubMembershipHistory Collection Navigation Reference
/// </summary>
public virtual ICollection<ClubMembershipHistory>? ClubMembershipHistories { get; set; }
/// <summary>
/// دوره‌های خرید پکیج — هر خرید پکیج یک Cycle جدید
/// </summary>
public virtual ICollection<ClubMembershipCycle>? Cycles { get; set; }
}
@@ -0,0 +1,64 @@
namespace CMSMicroservice.Domain.Entities.Club;
/// <summary>
/// دوره خرید پکیج — هر بار خرید پکیج ۵۶M یک Cycle جدید ایجاد می‌شود.
/// برای حل مشکل overwrite شدن ClubMembership.ActivatedAt در محاسبه کمیسیون.
/// </summary>
public class ClubMembershipCycle : BaseAuditableEntity
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; set; }
/// <summary>
/// User Navigation Property
/// </summary>
public virtual User User { get; set; }
/// <summary>
/// شناسه عضویت باشگاه
/// </summary>
public long ClubMembershipId { get; set; }
/// <summary>
/// ClubMembership Navigation Property
/// </summary>
public virtual ClubMembership ClubMembership { get; set; }
/// <summary>
/// شماره دور (1, 2, 3, ...)
/// </summary>
public int CycleNumber { get; set; }
/// <summary>
/// تاریخ خرید پکیج — این فیلد برای محاسبه کمیسیون هفتگی استفاده می‌شود
/// (به‌جای ClubMembership.ActivatedAt که overwrite می‌شد)
/// </summary>
public DateTime PackagePurchasedAt { get; set; }
/// <summary>
/// تاریخ شروع حالت جادویی (وقتی Balance=0 شد)
/// </summary>
public DateTime? MagicStartedAt { get; set; }
/// <summary>
/// تاریخ پایان حالت جادویی
/// </summary>
public DateTime? MagicCompletedAt { get; set; }
/// <summary>
/// نحوه خرید پکیج در این دور
/// </summary>
public PackagePurchaseMethod PurchaseMethod { get; set; }
/// <summary>
/// مبلغ پکیج (ریال)
/// </summary>
public long PackageAmount { get; set; }
/// <summary>
/// آیا این دور فعلی است؟ فقط یک رکورد true می‌باشد
/// </summary>
public bool IsCurrentCycle { get; set; }
}
@@ -18,7 +18,36 @@ public class UserWallet : BaseAuditableEntity
/// موجودی تخفیف - فقط برای خرید از فروشگاه باشگاه مشتریان
/// </summary>
public long DiscountBalance { get; set; }
#region Magic Wallet
/// <summary>
/// حالت کیف‌پول — Normal: کمیسیون فعال | Magic: شارژ ×2.5 بدون کمیسیون
/// </summary>
public WalletMode WalletMode { get; set; } = WalletMode.Normal;
/// <summary>
/// مجموع واریزی واقعی در دور جادویی فعلی (ریال) — سقف: MagicWalletMaxDeposit
/// </summary>
public long MagicTotalDeposited { get; set; }
/// <summary>
/// مجموع اعتبار داده‌شده در دور جادویی فعلی (ریال) — واریز × 2.5
/// </summary>
public long MagicTotalCredited { get; set; }
/// <summary>
/// زمان شروع دور جادویی فعلی
/// </summary>
public DateTime? MagicActivatedAt { get; set; }
/// <summary>
/// زمان پایان دور جادویی فعلی
/// </summary>
public DateTime? MagicCompletedAt { get; set; }
#endregion
//UserWalletChangeLog Collection Navigation Reference
public virtual ICollection<UserWalletChangeLog> UserWalletChangeLogs { get; set; }
}
@@ -26,4 +26,14 @@ public enum TransactionType
/// خرید از فروشگاه تخفیف
/// </summary>
DiscountShopPurchase = 13,
/// <summary>
/// واریز از درگاه به کیف‌پول جادویی (مبلغ واقعی)
/// </summary>
MagicWalletDeposit = 14,
/// <summary>
/// بونوس داخلی کیف‌پول جادویی (مبلغ × 1.5)
/// </summary>
MagicWalletBonus = 15,
}
@@ -0,0 +1,13 @@
namespace CMSMicroservice.Domain.Enums;
/// <summary>
/// حالت کیف‌پول — Normal: کمیسیون فعال | Magic: شارژ ×2.5 بدون کمیسیون
/// </summary>
public enum WalletMode
{
/// <summary>حالت عادی — کمیسیون و پورسانت فعال</summary>
Normal = 0,
/// <summary>حالت جادویی — شارژ ×2.5، بدون کمیسیون</summary>
Magic = 1
}
@@ -109,6 +109,7 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
public DbSet<ClubFeature> ClubFeatures => Set<ClubFeature>();
public DbSet<UserClubFeature> UserClubFeatures => Set<UserClubFeature>();
public DbSet<ClubMembershipHistory> ClubMembershipHistories => Set<ClubMembershipHistory>();
public DbSet<ClubMembershipCycle> ClubMembershipCycles => Set<ClubMembershipCycle>();
// Network
public DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances => Set<NetworkWeeklyBalance>();
@@ -0,0 +1,45 @@
using CMSMicroservice.Domain.Entities.Club;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations;
public class ClubMembershipCycleConfiguration : IEntityTypeConfiguration<ClubMembershipCycle>
{
public void Configure(EntityTypeBuilder<ClubMembershipCycle> builder)
{
builder.HasQueryFilter(p => !p.IsDeleted);
builder.Ignore(entity => entity.DomainEvents);
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).UseIdentityColumn();
builder.Property(entity => entity.UserId).IsRequired();
builder.Property(entity => entity.ClubMembershipId).IsRequired();
builder.Property(entity => entity.CycleNumber).IsRequired();
builder.Property(entity => entity.PackagePurchasedAt).IsRequired();
builder.Property(entity => entity.MagicStartedAt).IsRequired(false);
builder.Property(entity => entity.MagicCompletedAt).IsRequired(false);
builder.Property(entity => entity.PurchaseMethod).IsRequired();
builder.Property(entity => entity.PackageAmount).IsRequired();
builder.Property(entity => entity.IsCurrentCycle)
.IsRequired()
.HasDefaultValue(false);
// Relationships
builder.HasOne(entity => entity.User)
.WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(entity => entity.ClubMembership)
.WithMany(cm => cm.Cycles)
.HasForeignKey(entity => entity.ClubMembershipId)
.OnDelete(DeleteBehavior.Restrict);
// Indexes
builder.HasIndex(e => new { e.UserId, e.IsCurrentCycle })
.HasDatabaseName("IX_ClubMembershipCycle_UserId_IsCurrentCycle");
builder.HasIndex(e => e.PackagePurchasedAt)
.HasDatabaseName("IX_ClubMembershipCycle_PackagePurchasedAt");
}
}
@@ -1,4 +1,5 @@
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations;
@@ -20,5 +21,18 @@ public class UserWalletConfiguration : IEntityTypeConfiguration<UserWallet>
builder.Property(entity => entity.NetworkBalance).IsRequired(true);
builder.Property(entity => entity.DiscountBalance).IsRequired(true);
// Magic Wallet
builder.Property(entity => entity.WalletMode)
.IsRequired()
.HasDefaultValue(WalletMode.Normal);
builder.Property(entity => entity.MagicTotalDeposited)
.IsRequired()
.HasDefaultValue(0L);
builder.Property(entity => entity.MagicTotalCredited)
.IsRequired()
.HasDefaultValue(0L);
builder.Property(entity => entity.MagicActivatedAt).IsRequired(false);
builder.Property(entity => entity.MagicCompletedAt).IsRequired(false);
}
}
@@ -56,8 +56,14 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
}
// دریافت همه کاربرانی که عضو فعال باشگاه هستند
// ⚠️ Magic Wallet: کاربرهایی که در حالت جادویی هستند از کمیسیون خارج میشن
var magicModeUserIds = await _context.UserWallets
.Where(w => w.WalletMode == WalletMode.Magic)
.Select(w => w.UserId)
.ToHashSetAsync(cancellationToken);
var activeClubMemberUserIds = await _context.ClubMemberships
.Where(c => c.IsActive)
.Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId))
.Select(c => c.UserId)
.ToHashSetAsync(cancellationToken);
@@ -364,10 +370,11 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
}
var count = 0;
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
// ⚠️ از ClubMembershipCycle.PackagePurchasedAt استفاده میکنیم (نه ActivatedAt که overwrite میشد)
var currentCycle = await _context.ClubMembershipCycles
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsCurrentCycle, cancellationToken);
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
if (currentCycle?.PackagePurchasedAt >= startDate && currentCycle?.PackagePurchasedAt <= endDate)
{
count = 1;
}
@@ -3,7 +3,7 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>0.0.180</Version>
<Version>0.0.181</Version>
<DebugType>None</DebugType>
<DebugSymbols>False</DebugSymbols>
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
@@ -78,7 +78,7 @@
<Target Name="PushToFoursatNuget" AfterTargets="Pack" Condition="'$(CI)' != 'true'">
<PropertyGroup>
<NugetPackagePath>$(PackageOutputPath)/$(PackageId).$(Version).nupkg</NugetPackagePath>
<NugetPackagePath>/home/masoud/Apps/project/FourSat/CMS/src/CMSMicroservice.Protobuf/bin/Debug/$(PackageId).$(Version).nupkg</NugetPackagePath>
<PushCommand>dotnet nuget push "$(NugetPackagePath)" --source foursat-hosted --api-key admin:87zH26nbqT --skip-duplicate --configfile "$(MSBuildThisFileDirectory)../NuGet.config"</PushCommand>
</PropertyGroup>
@@ -74,6 +74,20 @@ service UserWalletContract
get: "/Customer/GetWithdrawalSettings"
};
};
// ============= Magic Wallet Methods =============
rpc InitiateMagicCharge(InitiateMagicChargeRequest) returns (InitiateMagicChargeResponse){
option (google.api.http) = {
post: "/Customer/InitiateMagicCharge"
body: "*"
};
};
rpc GetMagicWalletStatus(google.protobuf.Empty) returns (GetMagicWalletStatusResponse){
option (google.api.http) = {
get: "/Customer/GetMagicWalletStatus"
};
};
}
message CreateNewUserWalletRequest
{
@@ -140,6 +154,7 @@ message GetCustomerWalletResponse
int64 balance = 1;
int64 network_balance = 2;
int64 discount_balance = 3;
int32 wallet_mode = 4; // 0=Normal, 1=Magic
}
message GetCustomerWalletChangeLogRequest
@@ -200,4 +215,29 @@ message CustomerWithdrawalModel
message GetCustomerWithdrawalSettingsResponse
{
int64 min_withdrawal_amount = 1;
}
// ============= Magic Wallet Messages =============
message InitiateMagicChargeRequest
{
int64 amount = 1; // مبلغ واریزی واقعی (ریال)
}
message InitiateMagicChargeResponse
{
bool is_success = 1;
string gateway_url = 2;
string error_message = 3;
}
message GetMagicWalletStatusResponse
{
int32 wallet_mode = 1; // 0=Normal, 1=Magic
int64 magic_total_deposited = 2;
int64 magic_total_credited = 3;
int64 magic_max_deposit = 4;
int64 magic_remaining_deposit = 5;
int64 balance = 6;
google.protobuf.Timestamp magic_activated_at = 7;
}
@@ -1,5 +1,6 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyMagicWalletCharge;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -143,4 +144,46 @@ public class PaymentCallbackController : ControllerBase
$"{frontOfficeBaseUrl}/discount-store/order/{orderId}?payment=error");
}
}
/// <summary>
/// Callback برای شارژ کیف‌پول جادویی — زرین‌پال بعد از پرداخت کاربر را اینجا برمی‌گرداند
/// </summary>
[HttpGet("/api/wallet/verify-magic-charge")]
public async Task<IActionResult> MagicChargeCallback(
[FromQuery(Name = "Authority")] string? authority,
[FromQuery(Name = "Status")] string? status,
CancellationToken cancellationToken)
{
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268";
_logger.LogInformation(
"Magic charge callback received: Authority={Authority}, Status={Status}",
authority, status);
try
{
if (string.IsNullOrEmpty(authority))
{
_logger.LogError("Magic charge callback: Authority is missing");
return Redirect($"{frontOfficeBaseUrl}/magic-wallet?payment=error&reason=no-authority");
}
var result = await _sender.Send(new VerifyMagicWalletChargeCommand
{
Authority = authority,
Status = status ?? "NOK"
}, cancellationToken);
_logger.LogInformation(
"Magic charge completed successfully. Authority={Authority}",
authority);
return Redirect($"{frontOfficeBaseUrl}/magic-wallet?payment=success");
}
catch (Exception ex)
{
_logger.LogError(ex, "Magic charge callback error. Authority={Authority}", authority);
return Redirect($"{frontOfficeBaseUrl}/magic-wallet?payment=failed");
}
}
}
@@ -6,7 +6,9 @@ using CMSMicroservice.Application.UserOrderCQ.Queries.GetCustomerOrderHistory;
using CMSMicroservice.Application.OrderManagementCQ.Commands.UpdateOrderStatus;
using CMSMicroservice.Application.OrderManagementCQ.Commands.CancelOrderByAdmin;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Enums;
using AppModels = CMSMicroservice.Application.Common.Models;
@@ -272,7 +274,7 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
}
// Calculate amounts
const decimal vatRate = 0.09m;
const decimal vatRate = 0.10m;
long baseAmount = cartItems.Sum(c => c.Product.Price * c.Count);
long vatAmount = (long)(baseAmount * vatRate);
long totalAmount = baseAmount + vatAmount;
@@ -334,7 +336,54 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
};
_context.UserWalletChangeLogs.Add(walletLog);
// ═══ Magic Wallet: Entry / Exit Trigger ═══
if (wallet.Balance == 0)
{
var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == userId, context.CancellationToken);
if (wallet.WalletMode == WalletMode.Magic
&& wallet.MagicTotalDeposited >= SystemConstants.MagicWalletMaxDeposit)
{
// ── EXIT Magic Mode ──
// هر دو شرط: Balance=0 و سقف 100M پر شده
wallet.WalletMode = WalletMode.Normal;
wallet.MagicCompletedAt = DateTime.UtcNow;
var currentCycle = await _context.ClubMembershipCycles
.FirstOrDefaultAsync(c => c.UserId == userId && c.IsCurrentCycle,
context.CancellationToken);
if (currentCycle != null)
{
currentCycle.MagicCompletedAt = DateTime.UtcNow;
}
}
else if (wallet.WalletMode == WalletMode.Normal
&& user != null
&& user.PackagePurchaseMethod != PackagePurchaseMethod.None
&& user.ClubMembership?.IsActive == true)
{
// ── ENTER Magic Mode ──
wallet.WalletMode = WalletMode.Magic;
wallet.MagicActivatedAt = DateTime.UtcNow;
wallet.MagicCompletedAt = null;
wallet.MagicTotalDeposited = 0;
wallet.MagicTotalCredited = 0;
var currentCycle = await _context.ClubMembershipCycles
.FirstOrDefaultAsync(c => c.UserId == userId && c.IsCurrentCycle,
context.CancellationToken);
if (currentCycle != null)
{
currentCycle.MagicStartedAt = DateTime.UtcNow;
}
}
// ⚠️ اگه Magic باشه و Balance=0 ولی سقف پر نشده → هنوز Magic!
// کاربر میتونه دوباره شارژ کنه
}
// Create order
var order = new UserOrder
{
@@ -935,11 +984,11 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
public override Task<GetVATRateResponse> GetVATRate(Google.Protobuf.WellKnownTypes.Empty request, ServerCallContext context)
{
// VAT Rate for Iran: 9% (نرخ مالیات بر ارزش افزوده ایران)
// VAT Rate for Iran: 10% (نرخ مالیات بر ارزش افزوده ایران)
return Task.FromResult(new GetVATRateResponse
{
VatRate = 0.09,
VatPercentage = 9,
VatRate = 0.10,
VatPercentage = 10,
IsEnabled = true
});
}
@@ -3,13 +3,17 @@ using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.UserWalletCQ.Commands.CreateNewUserWallet;
using CMSMicroservice.Application.UserWalletCQ.Commands.UpdateUserWallet;
using CMSMicroservice.Application.UserWalletCQ.Commands.DeleteUserWallet;
using CMSMicroservice.Application.WalletCQ.Commands.ChargeMagicWallet;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetUserWallet;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetAllUserWalletByFilter;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWalletChangeLog;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Enums;
using Grpc.Core;
using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using System.Linq;
@@ -64,11 +68,15 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
var walletQuery = new GetUserWalletQuery { Id = userId };
var wallet = await _sender.Send(walletQuery, context.CancellationToken);
var walletEntity = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId, context.CancellationToken);
return new GetCustomerWalletResponse
{
Balance = wallet.Balance,
NetworkBalance = wallet.NetworkBalance,
DiscountBalance = wallet.DiscountBalance
DiscountBalance = wallet.DiscountBalance,
WalletMode = (int)(walletEntity?.WalletMode ?? WalletMode.Normal)
};
}
@@ -141,6 +149,57 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
return new Google.Protobuf.WellKnownTypes.Empty();
}
// ============= Magic Wallet Methods =============
public override async Task<InitiateMagicChargeResponse> InitiateMagicCharge(
InitiateMagicChargeRequest request, ServerCallContext context)
{
var userId = GetCurrentUserId();
var result = await _sender.Send(new ChargeMagicWalletCommand
{
UserId = userId,
Amount = request.Amount
}, context.CancellationToken);
return new InitiateMagicChargeResponse
{
IsSuccess = result.IsSuccess,
GatewayUrl = result.GatewayUrl ?? "",
ErrorMessage = result.ErrorMessage ?? ""
};
}
public override async Task<GetMagicWalletStatusResponse> GetMagicWalletStatus(
Google.Protobuf.WellKnownTypes.Empty request, ServerCallContext context)
{
var userId = GetCurrentUserId();
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId, context.CancellationToken);
if (wallet == null)
throw new RpcException(new Status(StatusCode.NotFound, "کیف پول یافت نشد"));
var response = new GetMagicWalletStatusResponse
{
WalletMode = (int)wallet.WalletMode,
MagicTotalDeposited = wallet.MagicTotalDeposited,
MagicTotalCredited = wallet.MagicTotalCredited,
MagicMaxDeposit = SystemConstants.MagicWalletMaxDeposit,
MagicRemainingDeposit = Math.Max(0, SystemConstants.MagicWalletMaxDeposit - wallet.MagicTotalDeposited),
Balance = wallet.Balance
};
if (wallet.MagicActivatedAt.HasValue)
{
response.MagicActivatedAt = Timestamp.FromDateTime(
DateTime.SpecifyKind(wallet.MagicActivatedAt.Value, DateTimeKind.Utc));
}
return response;
}
private long GetCurrentUserId()
{
if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0)