Files
docs/01-BUSINESS/balance-calculation-rules.md
T
masoodafar-web 996a6e885b feat: Update CURRENT-SPRINT.md with progress and task completion details
- Added last updated date and refined project status for Backend and Frontend.
- Updated completion percentages for FrontOffice UI and BFF.
- Documented completed tasks for BackOffice in the current sprint.
- Added new high-priority tasks related to commission calculation fixes.
- Resolved blockers and minor issues, providing a clearer progress summary.

docs: Create commission-calculation-fix.md for weekly commission calculation analysis

- Documented critical issues affecting commission calculations.
- Provided a detailed plan for fixing the commission calculation logic.
- Included code analysis, affected files, and step-by-step tasks for implementation.
- Outlined the expected timeline and important notes regarding changes.
2025-12-04 19:54:08 +03:30

14 KiB
Raw Blame History

Balance Calculation with Carryover Logic - Complete Guide

Date: 2025-12-01
Last Updated: 2025-12-04 (⚠️ تغییر مهم: سقف 300 برای هر دست، نه کل)
Status: Implemented (نیاز به اصلاح سقف دارد)
Migration: UpdateNetworkWeeklyBalanceWithCarryover


⚠️ اصلاحیه مهم بیزینس (2025-12-04)

مشکل شناسایی شده:

در پیاده‌سازی فعلی، سقف تعادل هفتگی 300 کل در نظر گرفته شده بود. اما طبق قانون صحیح بیزینس:

توضیح منطق فعلی (اشتباه) منطق صحیح
سقف 300 کل 300 برای هر دست
حداکثر تعادل 300 MIN(300, 300) = 300
حداکثر کل 300 300 + 300 = 600 (مجموع دو دست)

تفاوت در محاسبه:

منطق فعلی (اشتباه):

totalBalances = MIN(leftTotal, rightTotal)
cappedBalances = MIN(totalBalances, 300)  // ← سقف روی کل

منطق صحیح:

cappedLeftTotal = MIN(leftTotal, 300)   // ← سقف روی هر دست
cappedRightTotal = MIN(rightTotal, 300)
totalBalances = MIN(cappedLeftTotal, cappedRightTotal)

مثال عملی:

سناریو چپ راست منطق فعلی منطق صحیح
1 200 250 200 200
2 350 400 300 300
3 500 600 300 300

توجه: در مثال‌های بالا نتیجه یکسان است چون حداکثر یک تعادل همیشه MIN(300,300)=300 است. تفاوت در باقیمانده است:

مثال با چپ=500، راست=600:

روش تعادل باقیمانده چپ باقیمانده راست
فعلی 300 500 - 150 = 350 600 - 150 = 450
صحیح 300 200 (500-300) 300 (600-300)

تغییرات Configuration:

// ✅ تغییر نام و مقدار:
// قدیمی:
Key = "Commission.MaxWeeklyBalancesPerUser", Value = "300"

// جدید:
Key = "Commission.MaxWeeklyBalancesPerLeg", Value = "300"

📋 Configuration-Based Calculation

System Configurations Used:

// تمام مقادیر از جدول SystemConfigurations خوانده می‌شوند
Club.ActivationFee = 25,000,000 ریال (هزینه فعال‌سازی)
Commission.WeeklyPoolContributionPercent = 20% (سهم استخر)
Commission.MaxWeeklyBalancesPerLeg = 300 (⚠️ سقف تعادل هفتگی - هر دست)

توجه: کلید قدیمی MaxWeeklyBalancesPerUser باید به MaxWeeklyBalancesPerLeg تغییر کند.

Pool Contribution Calculation:

totalNewMembers = leftNewMembers + rightNewMembers
weeklyPoolContribution = totalNewMembers × activationFee × poolPercent
                       = totalNewMembers × 25,000,000 × 20%
                       = totalNewMembers × 5,000,000

مثال:
اگر 10 نفر جدید جذب شوند: 10 × 5,000,000 = 50,000,000 ریال به استخر اضافه می‌شود.


🚫 MaxWeeklyBalances Cap (محدودیت سقف - هر دست)

Logic (صحیح):

// ⚠️ سقف روی هر دست جداگانه اعمال می‌شود
cappedLeftTotal = MIN(leftTotal, maxBalancesPerLeg)   // 300
cappedRightTotal = MIN(rightTotal, maxBalancesPerLeg) // 300

// تعادل = کمترین مقدار بعد از اعمال سقف
totalBalances = MIN(cappedLeftTotal, cappedRightTotal)

// باقیمانده = مقدار قبل از سقف - سقف (نه از totalBalances)
leftRemainder = leftTotal - cappedLeftTotal
rightRemainder = rightTotal - cappedRightTotal

Example (جدید):

Week 5:
leftTotal = 350, rightTotal = 400
maxBalancesPerLeg = 300

cappedLeftTotal = MIN(350, 300) = 300
cappedRightTotal = MIN(400, 300) = 300

totalBalances = MIN(300, 300) = 300 ✅

// باقیمانده = اضافه‌ای که از سقف رد شده
leftRemainder = 350 - 300 = 50
rightRemainder = 400 - 300 = 100

مقایسه منطق قدیم vs جدید:

// ❌ منطق قدیم (اشتباه):
cappedBalances = MIN(totalBalances, 300)  // سقف روی کل
balancesConsumedPerSide = cappedBalances / 2
leftRemainder = leftTotal - balancesConsumedPerSide

// ✅ منطق جدید (صحیح):
cappedLeftTotal = MIN(leftTotal, 300)     // سقف روی هر دست
cappedRightTotal = MIN(rightTotal, 300)
totalBalances = MIN(cappedLeftTotal, cappedRightTotal)
leftRemainder = leftTotal - cappedLeftTotal  // باقیمانده از سقف هر دست

📊 Problem Statement

Previous (Incorrect) Logic:

// محاسبه تعداد کل اعضا در هر پا
leftLegBalances = CountAllMembers(userId, Left);
rightLegBalances = CountAllMembers(userId, Right);

// تعادل = کمترین مقدار
TotalBalances = MIN(leftLegBalances, rightLegBalances);

مشکلات:

  1. تعداد کل اعضا را می‌شمارد (نه فقط جدیدها)
  2. باقیمانده هفته قبل را نادیده می‌گیرد
  3. هر هفته از صفر شروع می‌کند

Current (Correct) Logic:

Formula:

leftTotal = leftNewMembers + leftCarryover
rightTotal = rightNewMembers + rightCarryover

TotalBalances = MIN(leftTotal, rightTotal)

leftRemainder = leftTotal - TotalBalances
rightRemainder = rightTotal - TotalBalances

Key Principles:

  1. Only count NEW members activated in current week
  2. Add carryover from previous week
  3. Calculate remainder for next week
  4. Recursive counting through entire tree structure

🔢 Example Calculations

Week 1 (2025-W48):

Tree Structure:

User A (Activated this week - 25M to pool)
├─ Left: User B (Activated this week - 25M)
└─ Right: User C (Activated this week - 25M)

Calculations:

User A:
  leftNewMembers = 1 (User B activated)
  rightNewMembers = 1 (User C activated)
  leftCarryover = 0 (first week)
  rightCarryover = 0 (first week)
  
  leftTotal = 1 + 0 = 1
  rightTotal = 1 + 0 = 1
  
  TotalBalances = MIN(1, 1) = 1
  
  leftRemainder = 1 - 1 = 0
  rightRemainder = 1 - 1 = 0

User B: TotalBalances = 0 (no children)
User C: TotalBalances = 0 (no children)

Pool Calculation:

Total Pool = 75M (3 activations × 25M)
Total Balances = 1 (only User A)
Value Per Balance = 75M ÷ 1 = 75M

Commission:
  User A = 1 × 75M = 75M

Week 2 (2025-W49):

Tree Structure:

User A
├─ Left: User B
│   ├─ Left: User D (NEW - activated this week - 25M)
│   └─ Right: User E (NEW - activated this week - 25M)
└─ Right: User C
    ├─ Left: User F (NEW - activated this week - 25M)
    └─ Right: User G (NEW - activated this week - 25M)

Calculations:

User B:
  leftNewMembers = 1 (User D)
  rightNewMembers = 1 (User E)
  leftCarryover = 0
  rightCarryover = 0
  
  leftTotal = 1 + 0 = 1
  rightTotal = 1 + 0 = 1
  TotalBalances = MIN(1, 1) = 1

User C:
  leftNewMembers = 1 (User F)
  rightNewMembers = 1 (User G)
  leftCarryover = 0
  rightCarryover = 0
  
  leftTotal = 1 + 0 = 1
  rightTotal = 1 + 0 = 1
  TotalBalances = MIN(1, 1) = 1

User A:
  leftNewMembers = 2 (D & E through B)
  rightNewMembers = 2 (F & G through C)
  leftCarryover = 0 (from week 1)
  rightCarryover = 0 (from week 1)
  
  leftTotal = 2 + 0 = 2
  rightTotal = 2 + 0 = 2
  TotalBalances = MIN(2, 2) = 2 ✅
  
  leftRemainder = 2 - 2 = 0
  rightRemainder = 2 - 2 = 0

Pool Calculation:

Total Pool = 100M (4 new activations × 25M)
Total Balances = 4 (A=2, B=1, C=1)
Value Per Balance = 100M ÷ 4 = 25M

Commission:
  User A = 2 × 25M = 50M ✅ (not 33.33M!)
  User B = 1 × 25M = 25M
  User C = 1 × 25M = 25M

Week 3 (2025-W50) - With Carryover:

Tree Structure:

User A
├─ Left: User B
│   ├─ Left: User D
│   │   └─ Left: User H (NEW - 25M)
│   └─ Right: User E
└─ Right: User C
    ├─ Left: User F
    └─ Right: User G

Calculations:

User D:
  leftNewMembers = 1 (User H)
  rightNewMembers = 0
  leftCarryover = 0
  rightCarryover = 0
  
  leftTotal = 1 + 0 = 1
  rightTotal = 0 + 0 = 0
  TotalBalances = MIN(1, 0) = 0
  
  leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
  rightRemainder = 0 - 0 = 0

User B:
  leftNewMembers = 1 (H through D)
  rightNewMembers = 0
  leftCarryover = 0 (from week 2)
  rightCarryover = 0
  
  leftTotal = 1 + 0 = 1
  rightTotal = 0 + 0 = 0
  TotalBalances = MIN(1, 0) = 0
  
  leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
  rightRemainder = 0 - 0 = 0

User A:
  leftNewMembers = 1 (H through B→D)
  rightNewMembers = 0
  leftCarryover = 0 (from week 2)
  rightCarryover = 0
  
  leftTotal = 1 + 0 = 1
  rightTotal = 0 + 0 = 0
  TotalBalances = MIN(1, 0) = 0
  
  leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
  rightRemainder = 0 - 0 = 0

Pool Calculation:

Total Pool = 25M (1 new activation)
Total Balances = 0 (no balanced pairs)
Value Per Balance = N/A

Commission: None this week
Carryover: User A, B, D each have 1 leftRemainder for week 4

🔄 Database Schema

NetworkWeeklyBalance Table:

ALTER TABLE NetworkWeeklyBalances ADD:
  -- New members this week
  LeftLegNewMembers INT NOT NULL DEFAULT 0,
  RightLegNewMembers INT NOT NULL DEFAULT 0,
  
  -- Carryover from previous week
  LeftLegCarryover INT NOT NULL DEFAULT 0,
  RightLegCarryover INT NOT NULL DEFAULT 0,
  
  -- Totals (new + carryover)
  LeftLegTotal INT NOT NULL DEFAULT 0,
  RightLegTotal INT NOT NULL DEFAULT 0,
  
  -- Remainder for next week
  LeftLegRemainder INT NOT NULL DEFAULT 0,
  RightLegRemainder INT NOT NULL DEFAULT 0

Deprecated Fields:

  • LeftLegBalances (still exists for backward compatibility)
  • RightLegBalances (still exists for backward compatibility)

💻 Implementation

Handler: CalculateWeeklyBalancesCommandHandler.cs

public async Task<int> Handle(CalculateWeeklyBalancesCommand request, CancellationToken cancellationToken)
{
    // 1. Load previous week's carryover
    var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber);
    var previousWeekCarryovers = await _context.NetworkWeeklyBalances
        .Where(x => x.WeekNumber == previousWeekNumber)
        .ToDictionaryAsync(x => x.UserId, x => new { x.LeftLegRemainder, x.RightLegRemainder });

    // 2. For each user in network
    foreach (var user in usersInNetwork)
    {
        // Get carryover
        var leftCarryover = previousWeekCarryovers.ContainsKey(user.Id) 
            ? previousWeekCarryovers[user.Id].LeftLegRemainder : 0;
        var rightCarryover = previousWeekCarryovers.ContainsKey(user.Id) 
            ? previousWeekCarryovers[user.Id].RightLegRemainder : 0;

        // Count NEW members (activated in this week)
        var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekNumber);
        var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekNumber);

        // Calculate totals
        var leftTotal = leftNewMembers + leftCarryover;
        var rightTotal = rightNewMembers + rightCarryover;

        // Calculate balance (min)
        var totalBalances = Math.Min(leftTotal, rightTotal);

        // Calculate remainder
        var leftRemainder = leftTotal - totalBalances;
        var rightRemainder = rightTotal - totalBalances;

        // Save to database
        var balance = new NetworkWeeklyBalance
        {
            UserId = user.Id,
            WeekNumber = request.WeekNumber,
            LeftLegNewMembers = leftNewMembers,
            RightLegNewMembers = rightNewMembers,
            LeftLegCarryover = leftCarryover,
            RightLegCarryover = rightCarryover,
            LeftLegTotal = leftTotal,
            RightLegTotal = rightTotal,
            TotalBalances = totalBalances,
            LeftLegRemainder = leftRemainder,
            RightLegRemainder = rightRemainder,
            // ...
        };
    }
}

private async Task<int> CountNewMembersRecursive(long userId, NetworkLeg leg, DateTime startDate, DateTime endDate)
{
    var child = await _context.Users
        .FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == leg);

    if (child == null) return 0;

    var count = 0;

    // Check if activated in this week
    var membership = await _context.ClubMemberships
        .FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive);

    if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
    {
        count = 1;
    }

    // Recursively count children
    var childLeft = await CountNewMembersRecursive(child.Id, NetworkLeg.Left, startDate, endDate);
    var childRight = await CountNewMembersRecursive(child.Id, NetworkLeg.Right, startDate, endDate);

    return count + childLeft + childRight;
}

📝 Key Points

  1. Only NEW activations count - filtered by ActivatedAt date
  2. Carryover persists - unused balances roll over to next week
  3. Recursive counting - includes entire subtree under each leg
  4. Week date ranges - ISO 8601 week format (Saturday to Friday)
  5. Idempotent - can recalculate with ForceRecalculate flag

🚀 Benefits

  1. Fair commission distribution - rewards balanced growth
  2. No lost balances - carryover ensures nothing is wasted
  3. Accurate tracking - distinguishes new vs existing members
  4. Scalable - works for large networks with recursive algorithm
  5. Auditable - full history of calculations in database

📞 Reference

  • Source Code: CMSMicroservice.Application/CommissionCQ/Commands/CalculateWeeklyBalances/
  • Migration: 20251201144400_UpdateNetworkWeeklyBalanceWithCarryover
  • Entity: CMSMicroservice.Domain/Entities/Network/NetworkWeeklyBalance.cs
  • Discussion: Telegram chat with Dr. Seif (2025-12-01)

Status: Production Ready
Last Updated: 2025-12-01