Files
CMS/src/CMSMicroservice.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommandHandler.cs
T
masoodafar-web 8518c7b3ec
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m9s
feat: Enhance club membership activation and commission pool validation
- Updated AcceptClubMembershipContractCommandHandler to set PackagePurchasedAt to the contract-signing time, ensuring accurate pool charging.
- Modified ActivateClubMembershipCommand to allow specifying a PackageId for forced activation, defaulting to the base package if not provided.
- Improved ActivateClubMembershipCommandHandler to handle new and existing memberships differently regarding pool charging.
- Added validation logic in GetWeeklyCommissionPoolQueryHandler to compare TotalPoolAmount against actual activations.
- Introduced validation fields in WeeklyCommissionPoolDto for tracking discrepancies and expected amounts.
- Updated stored procedures to ensure accurate calculations based on package purchases and to handle empty pools.
- Enhanced protobuf definitions to include validation fields for commission pools and network tree nodes.
- Added mapping for new fields in CommissionProfile and NetworkMembershipProfile.
- Created DiscountProductProfile for mapping discount product requests and responses.
2026-05-01 00:07:05 +03:30

441 lines
18 KiB
C#

using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.Commission;
using CMSMicroservice.Domain.Entities.History;
using CMSMicroservice.Domain.Enums;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
/// <summary>
/// Handler برای پذیرش قرارداد باشگاه مشتریان
/// این handler:
/// 1. کد OTP را تایید می‌کند
/// 2. قرارداد را در جدول UserContract ثبت می‌کند
/// 3. باشگاه مشتری را فعال می‌کند (IsActive = true)
/// 4. مبلغ را به Pool هفته جاری اضافه می‌کند
/// 5. ویژگی‌های باشگاه را به کاربر اعطا می‌کند
/// </summary>
public class AcceptClubMembershipContractCommandHandler
: IRequestHandler<AcceptClubMembershipContractCommand, AcceptClubMembershipContractResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IConfiguration _cfg;
private readonly IHashService _hashService;
private readonly ICurrentUserService _currentUser;
private readonly IWeekDefinitionRepository _weekRepository;
private readonly ILogger<AcceptClubMembershipContractCommandHandler> _logger;
private const int MaxAttempts = 5;
private const string OtpPurpose = "signClubContract";
public AcceptClubMembershipContractCommandHandler(
IApplicationDbContext context,
IConfiguration cfg,
IHashService hashService,
ICurrentUserService currentUser,
IWeekDefinitionRepository weekRepository,
ILogger<AcceptClubMembershipContractCommandHandler> logger)
{
_context = context;
_cfg = cfg;
_hashService = hashService;
_currentUser = currentUser;
_weekRepository = weekRepository;
_logger = logger;
}
public async Task<AcceptClubMembershipContractResponseDto> Handle(
AcceptClubMembershipContractCommand request,
CancellationToken cancellationToken)
{
// خواندن UserId از JWT (امن‌تر از دریافت از کلاینت)
if (!long.TryParse(_currentUser.UserId, out var userId))
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "کاربر احراز هویت نشده است"
};
_logger.LogInformation(
"Processing club membership contract for UserId: {UserId}",
userId
);
// 1. دریافت کاربر
var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User not found: {UserId}", userId);
throw new NotFoundException(nameof(User), userId);
}
// 2. بررسی خرید پکیج
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "برای امضای قرارداد باشگاه مشتریان ابتدا باید پکیج پایه را خریداری کنید"
};
}
// 3. بررسی عدم فعال بودن قبلی باشگاه
if (user.ClubMembership?.IsActive == true)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "شما قبلاً عضو باشگاه مشتریان شده‌اید"
};
}
// 4. تایید OTP
var otpResult = await VerifyOtpAsync(user.Mobile, request.OtpCode, cancellationToken);
if (!otpResult.Success)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = otpResult.Message
};
}
// 5. ثبت قرارداد در جدول UserContract
var contract = await _context.Contracts
.FirstOrDefaultAsync(c => c.Type == ContractType.ClubMembership, cancellationToken);
if (contract == null)
{
// اگر قرارداد وجود ندارد، یک قرارداد پیش‌فرض ایجاد کنید
contract = new Contract
{
Title = "قرارداد باشگاه مشتریان",
Description = "قوانین و مقررات باشگاه مشتریان کارابازار",
HtmlContent = request.ContractHtml,
Type = ContractType.ClubMembership
};
await _context.Contracts.AddAsync(contract, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
var userContract = new UserContract
{
UserId = user.Id,
ContractId = contract.Id,
SignGuid = request.SignGuid,
SignedPdfFile = request.ContractHtml
};
await _context.UserContracts.AddAsync(userContract, cancellationToken);
// 6. بارگذاری پکیج از آخرین خرید کاربر یا پکیج پایه
var latestPurchase = await _context.UserPackagePurchases
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.PurchasedAt)
.FirstOrDefaultAsync(cancellationToken);
var package = latestPurchase != null
? await _context.Packages.FirstOrDefaultAsync(p => p.Id == latestPurchase.PackageId && !p.IsDeleted, cancellationToken)
: await _context.Packages.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken);
if (package == null)
throw new NotFoundException("پکیج یافت نشد");
long giftValue = package.ActivationFee;
long activationFeeValue = package.ActivationFee;
_logger.LogInformation(
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}",
package.Id, package.Title, giftValue, activationFeeValue
);
// 7. فعال‌سازی باشگاه مشتریان
ClubMembership clubMembership;
bool isNewMembership = user.ClubMembership == null;
var activationDate = DateTime.Now;
if (isNewMembership)
{
clubMembership = new ClubMembership
{
UserId = user.Id,
IsActive = true,
ActivatedAt = activationDate,
FirstActivationDate = activationDate,
FirstPackageId = package.Id,
LastActivationDate = activationDate,
LastPackageId = package.Id,
InitialContribution = activationFeeValue,
GiftValue = giftValue,
TotalEarned = 0,
PurchaseMethod = user.PackagePurchaseMethod
};
await _context.ClubMemberships.AddAsync(clubMembership, cancellationToken);
_logger.LogInformation(
"Created new club membership for UserId {UserId} via {Method}, GiftValue: {GiftValue}",
user.Id,
user.PackagePurchaseMethod,
giftValue
);
}
else
{
clubMembership = user.ClubMembership!;
clubMembership.IsActive = true;
clubMembership.ActivatedAt = activationDate;
clubMembership.LastActivationDate = activationDate;
clubMembership.LastPackageId = package.Id;
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(clubMembership);
_logger.LogInformation(
"Reactivated club membership for UserId {UserId}",
user.Id
);
}
await _context.SaveChangesAsync(cancellationToken);
// 8. ثبت تاریخچه
var history = new ClubMembershipHistory
{
ClubMembershipId = clubMembership.Id,
UserId = clubMembership.UserId,
OldIsActive = !isNewMembership && !user.ClubMembership!.IsActive,
NewIsActive = true,
Action = ClubMembershipAction.Activated,
Reason = isNewMembership
? $"Initial activation via contract signing - {user.PackagePurchaseMethod}"
: $"Reactivated via contract signing - {user.PackagePurchaseMethod}",
PerformedBy = _currentUser.GetPerformedBy()
};
_context.ClubMembershipHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken);
// 8.5. آپدیت PackagePurchasedAt سایکل جاری به زمان امضای قرارداد
// دلیل: Pool در همین لحظه شارژ می‌شود — Validation باید به همین هفته اشاره کند
// سناریوی cross-week: کاربر هفته ۱۸ پکیج خریده، هفته ۱۹ قرارداد امضا کرده
// بدون این Fix: Cycle.PackagePurchasedAt = هفته ۱۸ ولی Pool هفته ۱۹ → اختلاف
// با این Fix: Cycle.PackagePurchasedAt = هفته ۱۹ = Pool هفته ۱۹ → درست
var currentCycle = await _context.ClubMembershipCycles
.FirstOrDefaultAsync(c => c.UserId == userId && c.IsCurrentCycle, cancellationToken);
if (currentCycle != null)
{
currentCycle.PackagePurchasedAt = activationDate;
_context.ClubMembershipCycles.Update(currentCycle);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Updated Cycle #{CycleNumber} PackagePurchasedAt to contract-signing time for UserId {UserId}",
currentCycle.CycleNumber,
userId
);
}
else
{
// در صورتی که Cycle وجود ندارد (legacy یا مسیر مستقیم AcceptContract)
// Cycle را همین‌جا بساز تا Validation درست کار کند
_logger.LogWarning(
"No IsCurrentCycle found for UserId {UserId} at AcceptContract — creating Cycle now",
userId
);
var maxCycleNumber = await _context.ClubMembershipCycles
.Where(c => c.ClubMembershipId == clubMembership.Id)
.MaxAsync(c => (int?)c.CycleNumber, cancellationToken) ?? 0;
var newCycle = new ClubMembershipCycle
{
UserId = userId,
ClubMembershipId = clubMembership.Id,
CycleNumber = maxCycleNumber + 1,
PackagePurchasedAt = activationDate,
PurchaseMethod = user.PackagePurchaseMethod,
PackageAmount = package.Price,
PackageId = package.Id,
IsCurrentCycle = true
};
_context.ClubMembershipCycles.Add(newCycle);
await _context.SaveChangesAsync(cancellationToken);
}
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
if (weeklyPool == null)
{
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
PackageId = package.Id,
TotalPoolAmount = activationFeeValue,
TotalBalances = 0,
ValuePerBalance = 0,
IsCalculated = false,
CalculatedAt = null
};
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
_logger.LogInformation(
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId,
activationFeeValue
);
}
else
{
weeklyPool.TotalPoolAmount += activationFeeValue;
_context.WeeklyCommissionPools.Update(weeklyPool);
_logger.LogInformation(
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
activationFeeValue,
currentWeekDefinitionId,
weeklyPool.TotalPoolAmount
);
}
await _context.SaveChangesAsync(cancellationToken);
// 10. اعمال ویژگی‌های پکیج — DIFF/تفاضل (Q20)
await ApplyFeatureDiffAsync(user.Id, clubMembership.Id, package.Id, isNewMembership, activationDate, cancellationToken);
_logger.LogInformation(
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
request.UserId,
userContract.Id,
clubMembership.Id
);
return new AcceptClubMembershipContractResponseDto
{
Success = true,
Message = "قرارداد باشگاه مشتریان با موفقیت ثبت شد و عضویت شما فعال گردید",
ContractId = userContract.Id
};
}
/// <summary>
/// دریافت شناسه تعریف هفته جاری
/// </summary>
private long GetCurrentWeekDefinitionId()
{
var week = _weekRepository.GetCurrentWeek();
if (week == null)
{
throw new InvalidOperationException("هفته جاری در سیستم تعریف نشده است");
}
return week.Id;
}
private async Task ApplyFeatureDiffAsync(
long userId, long membershipId, long packageId,
bool isNewMembership, DateTime activationDate,
CancellationToken cancellationToken)
{
var newFeatureIds = await _context.PackageFeatures
.Where(pf => pf.PackageId == packageId && pf.IsIncluded && !pf.IsDeleted)
.Select(pf => pf.ClubFeatureId)
.ToListAsync(cancellationToken);
if (!newFeatureIds.Any())
{
_logger.LogWarning("No PackageFeatures for PackageId {PackageId}", packageId);
return;
}
var currentFeatures = await _context.UserClubFeatures
.Where(ucf => ucf.UserId == userId && ucf.IsActive)
.ToListAsync(cancellationToken);
var currentFeatureIds = currentFeatures.Select(f => f.ClubFeatureId).ToHashSet();
var toAdd = newFeatureIds.Where(id => !currentFeatureIds.Contains(id)).ToList();
var toRemove = currentFeatures.Where(f => !newFeatureIds.Contains(f.ClubFeatureId)).ToList();
foreach (var feature in toRemove)
{
feature.IsActive = false;
feature.Notes = $"حذف شده بابت تغییر پکیج (PackageId: {packageId})";
}
if (toAdd.Any())
{
var newUserFeatures = toAdd.Select(featureId => new UserClubFeature
{
UserId = userId,
ClubMembershipId = membershipId,
ClubFeatureId = featureId,
GrantedAt = activationDate,
IsActive = true,
Notes = isNewMembership
? "اعطا شده هنگام امضای قرارداد"
: $"اضافه شده بابت خرید مجدد پکیج (PackageId: {packageId})"
}).ToList();
_context.UserClubFeatures.AddRange(newUserFeatures);
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Feature DIFF applied for UserId={UserId}: Added={Added}, Removed={Removed}, Kept={Kept}",
userId, toAdd.Count, toRemove.Count,
currentFeatureIds.Count - toRemove.Count);
}
private async Task<(bool Success, string Message)> VerifyOtpAsync(
string mobile,
string code,
CancellationToken cancellationToken)
{
var normalizedMobile = mobile.NormalizeIranMobile();
var now = DateTime.Now;
var otp = await _context.OtpTokens
.Where(o => o.Mobile == normalizedMobile
&& o.Purpose == OtpPurpose
&& !o.IsUsed
&& o.ExpiresAt > now)
.OrderByDescending(o => o.Created)
.FirstOrDefaultAsync(cancellationToken);
if (otp == null)
{
return (false, "کد تایید پیدا نشد یا منقضی شده است");
}
if (otp.Attempts >= MaxAttempts)
{
return (false, "تعداد تلاش‌ها بیش از حد مجاز است. لطفاً کد جدید دریافت کنید");
}
otp.Attempts++;
var secret = _cfg["Otp:Secret"] ?? throw new InvalidOperationException("Otp:Secret not configured");
if (!_hashService.VerifyHmacSha256Hex(code, otp.CodeHash, secret))
{
await _context.SaveChangesAsync(cancellationToken);
return (false, "کد تایید نادرست است");
}
// کد صحیح است - علامت‌گذاری به عنوان استفاده شده
otp.IsUsed = true;
await _context.SaveChangesAsync(cancellationToken);
return (true, "کد تایید صحیح است");
}
}