feat: Enhance club membership activation and commission pool validation
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m9s

- 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.
This commit is contained in:
masoodafar-web
2026-05-01 00:07:05 +03:30
parent 789aa0b870
commit 8518c7b3ec
18 changed files with 423 additions and 115 deletions
@@ -221,7 +221,54 @@ public class AcceptClubMembershipContractCommandHandler
_context.ClubMembershipHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken);
// 9. اضافه کردن مبلغ به Pool هفته جاری
// 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);
@@ -17,4 +17,10 @@ public record ActivateClubMembershipCommand : IRequest<bool>
/// فعال‌سازی اجباری توسط ادمین — بدون بررسی سفارش و کیف‌پول
/// </summary>
public bool ForceActivation { get; init; }
/// <summary>
/// شناسه پکیج — فقط در ForceActivation استفاده می‌شود.
/// اگر 0 باشد، پکیج پایه (IsBasePackage) استفاده می‌شود.
/// </summary>
public long PackageId { get; init; }
}
@@ -170,10 +170,19 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
await _context.SaveChangesAsync(cancellationToken);
}
// بارگذاری پکیج پایه برای فعال‌سازی اجباری
package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException("پکیج پایه یافت نشد");
// بارگذاری پکیج — اگر PackageId مشخص شده همان را لود کن، وگرنه پکیج پایه
if (request.PackageId > 0)
{
package = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == request.PackageId && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException($"پکیج {request.PackageId} یافت نشد");
}
else
{
package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException("پکیج پایه یافت نشد");
}
}
// 6. بررسی عضویت فعلی
@@ -226,10 +235,11 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
var hasCurrentCycle = await _context.ClubMembershipCycles
.AnyAsync(c => c.UserId == user.Id && c.IsCurrentCycle, cancellationToken);
if (existingMembership.IsActive && hasCurrentCycle)
// اگر چرخه فعال دارد — یعنی Activate قبلاً برای این خرید اجرا شده؛ جلوگیری از ثبت Cycle تکراری
if (hasCurrentCycle)
{
_logger.LogInformation(
"User {UserId} is already an active club member with a running cycle",
"User {UserId} already has a running cycle — skipping duplicate activation",
user.Id
);
return true;
@@ -307,48 +317,60 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
await _context.SaveChangesAsync(cancellationToken);
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
if (weeklyPool == null)
// عضو جدید (isNewMembership=true): Pool توسط AcceptClubMembershipContract شارژ می‌شود
// چون کاربر قبل از امضای قرارداد IsActive=false است و قرارداد فقط یک‌بار امضا می‌شود.
// خرید مجدد (isNewMembership=false): قرارداد مجدد امضا نمی‌شود — Pool همین‌جا شارژ می‌شود.
if (!isNewMembership)
{
// ایجاد Pool جدید برای این هفته × این پکیج
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
PackageId = package.Id,
TotalPoolAmount = activationFeeValue,
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه
IsCalculated = false,
CalculatedAt = null
};
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
_logger.LogInformation(
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId,
giftValue
);
if (weeklyPool == null)
{
// ایجاد Pool جدید برای این هفته × این پکیج
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);
}
else
{
// اضافه کردن به Pool موجود
weeklyPool.TotalPoolAmount += activationFeeValue;
_context.WeeklyCommissionPools.Update(weeklyPool);
_logger.LogInformation(
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
activationFeeValue,
currentWeekDefinitionId,
weeklyPool.TotalPoolAmount
"New member — Pool charge deferred to AcceptClubMembershipContract for UserId {UserId}",
user.Id
);
}
await _context.SaveChangesAsync(cancellationToken);
// 9. اعمال ویژگی‌های پکیج — DIFF/تفاضل (Q20)
await ApplyFeatureDiffAsync(user.Id, entity.Id, package.Id, isNewMembership, activationDate, cancellationToken);
@@ -266,12 +266,16 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var count = 0;
// اگر فرزند در این هفته پکیج خریده (Cycle جدید ساخته شده)، 1 امتیاز
// ⚠️ از ClubMembershipCycle.PackagePurchasedAt استفاده میکنیم (نه ActivatedAt که overwrite میشد)
var currentCycle = await _context.ClubMembershipCycles
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsCurrentCycle, cancellationToken);
// ⭐ بر اساس بازه تاریخی هفته، نه IsCurrentCycle
// IsCurrentCycle فقط آخرین خرید را نشان می‌دهد — برای تاریخچه‌ای (هفته‌های گذشته)
// باید از PackagePurchasedAt استفاده کرد که تاریخ دقیق خرید را نگه می‌دارد
var hadCycleInWeek = await _context.ClubMembershipCycles
.AnyAsync(x => x.UserId == child.Id
&& x.PackagePurchasedAt >= startDate
&& x.PackagePurchasedAt <= endDate,
cancellationToken);
if (currentCycle?.PackagePurchasedAt >= startDate && currentCycle?.PackagePurchasedAt <= endDate)
if (hadCycleInWeek)
{
count = 1;
}
@@ -41,6 +41,61 @@ public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyComm
})
.FirstOrDefaultAsync(cancellationToken);
return pool ?? new WeeklyCommissionPoolDto();
pool ??= new WeeklyCommissionPoolDto();
// ── Q29: اعتبارسنجی TotalPoolAmount در برابر فعال‌سازی‌های واقعی ──
await ValidatePoolAmountAsync(pool, request, cancellationToken);
return pool;
}
/// <summary>
/// TotalPoolAmount استخر را با تعداد فعال‌سازی‌های ثبت‌شده (AcceptContract + ActivateClubMembership) مقایسه می‌کند.
/// نتیجه در فیلدهای Validation... ذخیره می‌شود.
/// </summary>
private async Task ValidatePoolAmountAsync(WeeklyCommissionPoolDto pool, GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
{
var packageId = pool.PackageId > 0 ? pool.PackageId : request.PackageId;
if (packageId is null or 0)
return;
var weekDef = _weekDefinitionRepository.GetWeekById(request.WeekDefinitionId);
if (weekDef is null)
return;
var activationFee = await _context.Packages
.AsNoTracking()
.Where(p => p.Id == packageId.Value)
.Select(p => p.ActivationFee)
.FirstOrDefaultAsync(cancellationToken);
if (activationFee <= 0)
return;
// شمارش Cycle‌هایی که Pool آن‌ها واقعاً شارژ شده:
// - عضو جدید: Pool توسط AcceptContract شارژ می‌شود → UserContract وجود دارد
// - خرید مجدد: Pool در ActivateClubMembership شارژ می‌شود → IsCurrentCycle نبوده (قبلاً active بوده)
// - نباید از IsActive استفاده شود: وضعیت تاریخی نیست (عضو ممکن است بعداً غیرفعال شده باشد)
//
// قانون: Cycle شارژ شده = یا قرارداد دارد (عضو جدید) یا IsActive قبلاً true بوده (خرید مجدد)
// ساده‌ترین معادل: Cycle هایی که ClubMembership.ActivatedAt در بازه هفته است (زمان واقعی شارژ Pool)
var totalActivations = await _context.ClubMembershipCycles
.AsNoTracking()
.Where(c => c.PackageId == packageId.Value
&& c.PackagePurchasedAt >= weekDef.StartDate
&& c.PackagePurchasedAt < weekDef.EndDate
&& _context.UserContracts.Any(uc =>
uc.UserId == c.UserId
&& _context.Contracts.Any(con =>
con.Id == uc.ContractId
&& con.Type == ContractType.ClubMembership)))
.CountAsync(cancellationToken);
var expectedAmount = (long)totalActivations * activationFee;
pool.ValidationActivationCount = totalActivations;
pool.ValidationExpectedAmount = expectedAmount;
pool.ValidationDiscrepancy = pool.TotalPoolAmount - expectedAmount;
pool.ValidationIsValid = pool.TotalPoolAmount == expectedAmount;
}
}
@@ -16,4 +16,14 @@ public class WeeklyCommissionPoolDto
public DateTimeOffset Created { get; set; }
public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty;
// ── Validation Fields (Q29) ──
/// <summary>آیا TotalPoolAmount با تعداد فعال‌سازی‌های ثبت‌شده در دیتابیس مطابقت دارد؟</summary>
public bool ValidationIsValid { get; set; } = true;
/// <summary>مبلغ مورد انتظار بر اساس شمارش فعال‌سازی‌ها × ActivationFee</summary>
public long ValidationExpectedAmount { get; set; }
/// <summary>اختلاف: TotalPoolAmount - ValidationExpectedAmount</summary>
public long ValidationDiscrepancy { get; set; }
/// <summary>تعداد فعال‌سازی‌های شناسایی‌شده برای این هفته و پکیج</summary>
public int ValidationActivationCount { get; set; }
}
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
@@ -14,15 +15,18 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
private readonly ISender _sender;
private readonly ILogger<CreateManualPaymentCommandHandler> _logger;
public CreateManualPaymentCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser,
ISender sender,
ILogger<CreateManualPaymentCommandHandler> logger)
{
_context = context;
_currentUser = currentUser;
_sender = sender;
_logger = logger;
}
@@ -152,6 +156,29 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
// 10. ذخیره همه تغییرات
await _context.SaveChangesAsync(cancellationToken);
// 11. فعال‌سازی باشگاه مشتریان — ساخت Cycle و آماده‌سازی برای AcceptContract
// این مرحله تضمین می‌کند که:
// الف) ClubMembershipCycle ساخته شود تا والدین امتیاز «عضو جدید» بگیرند
// ب) Pool توسط AcceptContract (که کاربر در FO امضا می‌کند) شارژ شود
try
{
await _sender.Send(new ActivateClubMembershipCommand
{
UserId = request.UserId,
ForceActivation = true,
PackageId = package.Id
}, cancellationToken);
}
catch (Exception activateEx)
{
// عضویت باشگاه اختیاری است — خطا نباید پرداخت دستی را برگرداند
_logger.LogWarning(
activateEx,
"Club membership activation failed after manual payment for UserId {UserId} — payment recorded successfully",
request.UserId
);
}
_logger.LogInformation(
"Manual membership payment created successfully. " +
"ManualPaymentId: {Id}, UserId: {UserId}, TransactionId: {TransactionId}, " +
@@ -118,7 +118,10 @@ public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, N
ActivationWeekDefinitionId = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDefinitionId")) ? null : reader.GetInt64(reader.GetOrdinal("ActivationWeekDefinitionId")),
ActivationWeekDisplayName = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDisplayName")) ? null : reader.GetString(reader.GetOrdinal("ActivationWeekDisplayName")),
IsActivatedInTargetWeek = Convert.ToBoolean(reader.GetValue(reader.GetOrdinal("IsActivatedInTargetWeek"))),
UserCreated = new DateTimeOffset(reader.GetDateTime(reader.GetOrdinal("UserCreated")))
UserCreated = new DateTimeOffset(reader.GetDateTime(reader.GetOrdinal("UserCreated"))),
IsNewActivation = reader.IsDBNull(reader.GetOrdinal("IsNewActivation")) ? null : (bool?)(reader.GetInt32(reader.GetOrdinal("IsNewActivation")) == 1),
PackageName = reader.IsDBNull(reader.GetOrdinal("PackageName")) ? null : reader.GetString(reader.GetOrdinal("PackageName")),
PackageId = reader.IsDBNull(reader.GetOrdinal("PackageId")) ? null : reader.GetInt64(reader.GetOrdinal("PackageId"))
};
results.Add(node);
@@ -160,7 +163,10 @@ public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, N
ActivationWeekDefinitionId = flatNode.ActivationWeekDefinitionId,
ActivationWeekDisplayName = flatNode.ActivationWeekDisplayName,
IsActivatedInTargetWeek = flatNode.IsActivatedInTargetWeek,
UserCreated = flatNode.UserCreated
UserCreated = flatNode.UserCreated,
IsNewActivation = flatNode.IsNewActivation,
PackageName = flatNode.PackageName,
PackageId = flatNode.PackageId
};
}
@@ -47,7 +47,19 @@ public class NetworkTreeDto
/// تاریخ ایجاد کاربر
/// </summary>
public DateTimeOffset UserCreated { get; set; }
/// <summary>
/// آیا فعال‌سازی در هفته هدف اولین بار بوده؟ null = بدون Cycle
/// </summary>
public bool? IsNewActivation { get; set; }
/// <summary>
/// نام پکیج موثر (هفته هدف یا پکیج فعلی)
/// </summary>
public string? PackageName { get; set; }
public long? PackageId { get; set; }
public NetworkTreeDto? LeftChild { get; set; }
public NetworkTreeDto? RightChild { get; set; }
}
@@ -20,6 +20,19 @@ public class NetworkTreeNodeDto
public string? ActivationWeekDisplayName { get; set; }
public bool IsActivatedInTargetWeek { get; set; }
public DateTimeOffset UserCreated { get; set; }
/// <summary>
/// آیا فعال‌سازی در هفته هدف اولین بار بوده (true) یا خرید مجدد (false)؟
/// null یعنی Cycle‌ای ندارد
/// </summary>
public bool? IsNewActivation { get; set; }
/// <summary>
/// نام پکیج موثر (هفته هدف یا پکیج فعلی)
/// </summary>
public string? PackageName { get; set; }
public long? PackageId { get; set; }
/// <summary>
/// نام کامل کاربر