diff --git a/business/BIZ-PACKAGE-BASED-SYSTEM.md b/business/BIZ-PACKAGE-BASED-SYSTEM.md
index 7b58014..b0f5494 100644
--- a/business/BIZ-PACKAGE-BASED-SYSTEM.md
+++ b/business/BIZ-PACKAGE-BASED-SYSTEM.md
@@ -1,9 +1,9 @@
# 📦 سیستم مبتنی بر پکیج (Package-Based System)
> **وضعیت:** تاییدشده — آماده پیادهسازی
-> **تاریخ بروزرسانی:** ۶ اسفند ۱۴۰۴
-> **نسخه:** v4 (تحلیل جامع سایدافکتها — ۴۴ مورد جدید)
-> **تاثیرگذاری:** زیاد — **۹۰+** تغییر در ۶ لایه (۴۸ اصلی + ۴۴ سایدافکت)
+> **تاریخ بروزرسانی:** ۷ اسفند ۱۴۰۴
+> **نسخه:** v5 (قرارداد یکبار + فیچر DIFF + تاریخچه فعالسازی)
+> **تاثیرگذاری:** زیاد — **۹۵+** تغییر در ۶ لایه (۵۱ اصلی + ۴۴ سایدافکت)
---
@@ -37,6 +37,11 @@
| Q16 | NetworkWeeklyBalance + PackageId | ✅ **هر رکورد تعادل = per-package** — carryover هم جداگانه |
| Q17 | گزارش پورسانت FO | ✅ **breakdown per-package** — مشتری ببیند از هر پکیج چقدر |
| Q18 | گزارش پورسانت BO | ✅ **فیلتر بر اساس پکیج** — ادمین بر اساس پکیج فیلتر کند |
+| Q19 | قرارداد باشگاه — چند بار؟ | ✅ **فقط یک بار در طول عمر** — قرارداد فقط اولین خرید امضا میشود. خرید مجدد بدون قرارداد |
+| Q20 | فیچرها در خرید مجدد — چطور؟ | ✅ **DIFF/تفاضل** — مقایسه فیچرهای فعلی با پکیج جدید. فقط اختلاف اعمال میشود |
+| Q21 | ردیابی فعالسازی ClubMembership | ✅ **FirstActivation + LastActivation** — ۴ فیلد: `FirstActivationDate` + `FirstPackageId` + `LastActivationDate` + `LastPackageId` |
+| Q22 | تشخیص فعالشدگان هفته | ✅ **از `LastActivationDate`** — هر کسی که `LastActivationDate` در بازه هفته باشد |
+| Q23 | Carryover تعادل هفتگی | ✅ **strictly per-package** — اگر کاربر پکیج عوض کرد، carryover پکیج قبلی شمرده نمیشود |
---
@@ -93,8 +98,8 @@
| G2 | `InitiateBasePackagePaymentCommandHandler` | `PackagePurchaseMethod != None` → fail | مسدود | ✅ اجازه بعد تکمیل چرخه |
| G3 | `PurchasePackageCommandHandler` | `PackagePurchaseMethod != None` → throw | مسدود | ✅ اجازه بعد تکمیل چرخه |
| G4 | `CheckAndProcessDayaLoansCommandHandler` | `hasPreviousCycle` → skip | عمدی ✅ | ❌ حفظ (دایا فقط دور اول) |
-| G5 | `AcceptClubMembershipContractCommandHandler` | `IsActive == true` → fail | مسدود | ✅ اجازه re-contract |
-| G6 | `ActivateClubMembershipCommandHandler` | `IsActive == true` → return true | short-circuit | ✅ باید چرخه جدید بسازه |
+| G5 | `AcceptClubMembershipContractCommandHandler` | `IsActive == true` → fail | مسدود | ✅ حفظ — قرارداد فقط یک بار (Q19). خرید مجدد → Skip قرارداد |
+| G6 | `ActivateClubMembershipCommandHandler` | `IsActive == true` → return true | short-circuit | ✅ باید چرخه جدید بسازه + فیچر DIFF (Q20) |
| G7 | JWT Claim `HasPurchasedPackage` | permanent true | UI مسدود | ✅ اضافه `CanRepurchase` |
### ۳.۵ Root Cause — خرید مجدد کار نمیکند
@@ -106,8 +111,11 @@ EXIT Magic Mode (UserOrderService.cs):
✅ cycle.MagicCompletedAt = now
❌ MISSING: user.PackagePurchaseMethod = None ← Guards G1-G3 مسدود میمانند
- ❌ MISSING: membership.IsActive = false ← Guards G5-G6 مسدود میمانند
+ ❌ MISSING: cycle.IsCurrentCycle = false ← آماده چرخه جدید
❌ MISSING: JWT CanRepurchase = true ← UI دکمه خرید نشان نمیدهد
+
+ ℹ️ membership.IsActive حفظ میشود (قرارداد یکبار — Q19)
+ ℹ️ فیچرها حفظ میشوند — در خرید مجدد DIFF اعمال میشود (Q20)
```
**راهحل:** در EXIT Magic Mode، وضعیت کاربر ریست شود تا بتواند پکیج جدید بخرد.
@@ -178,12 +186,68 @@ public class PackageFeature : BaseAuditableEntity
| Entity | فیلد جدید | توضیح |
|--------|-----------|-------|
-| `ClubMembership` | `long? PackageId` + FK | آخرین پکیج خریداریشده |
+| `ClubMembership` | `DateTime FirstActivationDate` | اولین فعالسازی — فقط یک بار ست میشود (v5 — Q21) |
+| `ClubMembership` | `long FirstPackageId` + FK | اولین پکیج خریداریشده (v5 — Q21) |
+| `ClubMembership` | `DateTime LastActivationDate` | آخرین/جاری فعالسازی — هر خرید بروزرسانی میشود (v5 — Q21) |
+| `ClubMembership` | `long LastPackageId` + FK | آخرین/جاری پکیج (v5 — Q21) |
+| `ClubMembership` | حذف `ActivatedAt` | ← جایگزین با First/LastActivationDate |
| `ClubMembershipCycle` | `long PackageId` + FK | پکیج این چرخه |
| `WeeklyCommissionPool` | `long PackageId` + FK | Pool جداگانه هر پکیج |
| `UserCommissionPayout` | `long PackageId` + FK | از کدام Pool |
| **`NetworkWeeklyBalance`** | **`long PackageId` + FK** ← v3 | **هر رکورد تعادل = per-package** |
+> **تغییر v5:** `ClubMembership.ActivatedAt` حذف شد و به دو فیلد `FirstActivationDate` و `LastActivationDate` تبدیل شد.
+> `FirstActivationDate` فقط در اولین فعالسازی ست میشود و هیچوقت تغییر نمیکند.
+> `LastActivationDate` در هر خرید مجدد بروزرسانی میشود و مبنای تشخیص "فعالشدگان این هفته" است (Q22).
+
+**ClubMembership entity (v5):**
+
+```csharp
+public class ClubMembership : BaseAuditableEntity
+{
+ public long UserId { get; set; }
+ public virtual User User { get; set; }
+
+ public bool IsActive { get; set; }
+
+ // === v5: First/Last Activation Tracking (Q21) ===
+ ///
+ /// اولین فعالسازی — فقط یک بار ست میشود، هیچوقت overwrite نمیشود
+ ///
+ public DateTime FirstActivationDate { get; set; }
+
+ ///
+ /// اولین پکیج خریداریشده — فقط یک بار ست میشود
+ ///
+ public long FirstPackageId { get; set; }
+ public virtual Package FirstPackage { get; set; }
+
+ ///
+ /// آخرین/جاری فعالسازی — هر خرید مجدد بروزرسانی میشود
+ /// مبنای تشخیص "فعالشدگان این هفته" (Q22)
+ ///
+ public DateTime LastActivationDate { get; set; }
+
+ ///
+ /// آخرین/جاری پکیج — هر خرید مجدد بروزرسانی میشود
+ /// مبنای carryover per-package (Q23)
+ ///
+ public long LastPackageId { get; set; }
+ public virtual Package LastPackage { get; set; }
+
+ // === فیلدهای فعلی (حفظ) ===
+ public long InitialContribution { get; set; }
+ public long GiftValue { get; set; }
+ public long TotalEarned { get; set; }
+ public PackagePurchaseMethod PurchaseMethod { get; set; }
+
+ // === Navigation ===
+ public virtual ICollection? UserClubFeatures { get; set; }
+ public virtual ICollection? ClubMembershipHistories { get; set; }
+ public virtual ICollection? Cycles { get; set; }
+}
+```
+
**Constraintهای جدید:**
- `WeeklyCommissionPool` → Unique(`WeekDefinitionId`, `PackageId`)
- `NetworkWeeklyBalance` → Unique(`UserId`, `WeekDefinitionId`, `PackageId`) ← v3
@@ -242,19 +306,21 @@ stateDiagram-v2
NoPurchase --> PackagePurchased: خرید پکیج\n(هر پکیجی)
- PackagePurchased --> ClubActivated: فعالسازی باشگاه\n(OTP + قرارداد)
+ PackagePurchased --> ClubActivated: فعالسازی باشگاه\n(OTP + قرارداد — فقط بار اول)
ClubActivated --> Shopping: خرج Balance\nدر فروشگاه
Shopping --> MagicMode: Balance == 0
- MagicMode --> MagicCharging: شارژ + خرج\n(تا سقف 1B)
+ MagicMode --> MagicCharging: شارژ + خرج\n(تا سقف per-package)
MagicCharging --> MagicMode: ادامه
- MagicMode --> CycleComplete: Balance == 0\nAND Deposit ≥ 1B
+ MagicMode --> CycleComplete: Balance == 0\nAND Deposit ≥ MaxDeposit
- CycleComplete --> NoPurchase: ریست وضعیت\nآماده خرید مجدد
+ CycleComplete --> RePurchase: ریست PackagePurchaseMethod\n(membership فعال باقی — Q19)
+
+ RePurchase --> ClubActivated: خرید مجدد\n(بدون قرارداد + فیچر DIFF)
```
### ۵.۲ ریست وضعیت بعد تکمیل چرخه (EXIT Magic Mode)
@@ -266,17 +332,203 @@ wallet.MagicCompletedAt = DateTime.UtcNow;
cycle.MagicCompletedAt = DateTime.UtcNow;
// ✅ اضافه شود:
-user.PackagePurchaseMethod = PackagePurchaseMethod.None; // اجازه خرید مجدد
-membership.IsActive = false; // اجازه re-contract
+user.PackagePurchaseMethod = PackagePurchaseMethod.None; // اجازه خرید مجدد (G1-G3)
cycle.IsCurrentCycle = false; // آماده چرخه جدید
+
+// ❌ membership.IsActive حفظ میشود! (قرارداد یکبار — Q19)
+// ❌ فیچرها حفظ میشوند! (DIFF در خرید بعدی اعمال میشود — Q20)
```
+> **تفاوت v5 با v4:** `membership.IsActive = false` حذف شد. قرارداد فقط یک بار امضا میشود و عضویت فعال باقی میماند. در خرید مجدد، فقط فیچر DIFF + شارژ wallet + Commission انجام میشود.
+
### ۵.۳ نکات مهم
1. **دایا فقط دور اول** — بعد از دور اول، فقط IPG مجاز
2. **هر خرید = Commission contribution** — ActivationFee به Pool آن پکیج
3. **PackagePurchaseMethod ریست** بعد تکمیل چرخه
-4. **فیچرها بر اساس پکیج جدید** — ممکنه متفاوت باشه
+4. **فیچرها بر اساس پکیج جدید — DIFF/تفاضل اعمال میشود (Q20)**
+
+### ۵.۴ قرارداد باشگاه — فقط یک بار (v5 — Q19)
+
+> ⚠️ **تغییر بنیادی v5:** قرارداد باشگاه مشتریان **فقط یک بار** در طول عمر کاربر امضا میشود.
+
+```
+سناریو:
+ خرید اول (پکیج پایه):
+ ✅ OTP + امضای قرارداد + فعالسازی باشگاه
+ ✅ فیچرهای پکیج پایه فعال
+ ✅ قرارداد ثبت شد → UserContract record
+
+ خرید دوم (پکیج پایه مجدد):
+ ✅ پرداخت + شارژ wallet + Commission
+ ❌ قرارداد مجدد نمیشود (Q19)
+ ✅ فیچرها بدون تغییر (همان پکیج)
+
+ خرید سوم (پکیج نقرهای):
+ ✅ پرداخت + شارژ wallet + Commission
+ ❌ قرارداد مجدد نمیشود (Q19)
+ ✅ فیچر DIFF: حذف فیچرهای پایهای که نقرهای ندارد + اضافه فیچرهای جدید نقرهای
+```
+
+**فلوی خرید مجدد (بدون قرارداد):**
+
+```mermaid
+flowchart TD
+ A["EXIT Magic Mode\n(چرخه قبلی تکمیل شد)"] --> B["ریست PackagePurchaseMethod=None\n+ JWT: CanRepurchase=true"]
+ B --> C["کاربر پکیج جدید انتخاب میکند"]
+ C --> D["پرداخت\n(IPG / دایا دور اول)"]
+ D --> E["Verify Payment"]
+ E --> F["شارژ Balance + DiscountBalance"]
+ F --> G{"قرارداد دارد؟\n(UserContract exists?)"}
+ G -->|بله — Skip| H["فیچر DIFF (Q20)"]
+ G -->|خیر — اولین خرید| I["OTP + امضای قرارداد"]
+ I --> H
+ H --> J["بروزرسانی ClubMembership\nLastActivationDate + LastPackageId"]
+ J --> K["ساخت ClubMembershipCycle جدید"]
+ K --> L["ActivationFee → Pool پکیج"]
+```
+
+### ۵.۵ الگوریتم DIFF فیچرها (v5 — Q20)
+
+> ⚠️ **فیچرها بر اساس تفاضل (DIFF) اعمال میشوند — نه تخصیص کامل مجدد.**
+
+```
+ورودی:
+ currentFeatures = UserClubFeatures WHERE UserId = X AND IsActive = true
+ newFeatures = PackageFeatures WHERE PackageId = newPackage.Id AND IsIncluded = true
+
+الگوریتم:
+ toAdd = newFeatures - currentFeatures (فیچرهای جدید که قبلاً نداشت)
+ toRemove = currentFeatures - newFeatures (فیچرهای قبلی که پکیج جدید ندارد)
+ toKeep = currentFeatures ∩ newFeatures (مشترک — بدون تغییر)
+
+اقدامات:
+ foreach feature in toAdd:
+ INSERT UserClubFeature (UserId, ClubFeatureId, IsActive=true)
+ Notes = "اضافه شده بابت خرید پکیج [نام پکیج]"
+
+ foreach feature in toRemove:
+ UPDATE UserClubFeature SET IsActive = false
+ Notes = "حذف شده بابت تغییر از پکیج [قبلی] به [جدید]"
+
+ foreach feature in toKeep:
+ بدون تغییر — فیچر فعال باقی میماند
+```
+
+**مثال عملی:**
+
+```
+پکیج پایه فیچرها: [چاتیکا, بیمه, سفر, آموزش]
+پکیج نقرهای فیچرها: [چاتیکا, بیمه]
+
+کاربر "علی" (فعلاً پکیج پایه) → خرید پکیج نقرهای:
+ currentFeatures = [چاتیکا, بیمه, سفر, آموزش]
+ newFeatures = [چاتیکا, بیمه]
+
+ toKeep = [چاتیکا, بیمه] → بدون تغییر
+ toRemove = [سفر, آموزش] → IsActive = false
+ toAdd = [] → هیچ فیچر جدیدی نیست
+
+کاربر "سارا" (فعلاً پکیج نقرهای) → خرید پکیج پایه:
+ currentFeatures = [چاتیکا, بیمه]
+ newFeatures = [چاتیکا, بیمه, سفر, آموزش]
+
+ toKeep = [چاتیکا, بیمه] → بدون تغییر
+ toRemove = [] → هیچی حذف نمیشه
+ toAdd = [سفر, آموزش] → UserClubFeature جدید ساخته میشه
+```
+
+**کد پیشنهادی:**
+
+```csharp
+// FeatureDiffService.cs — لاجیک DIFF فیچرها
+public async Task ApplyFeatureDiffAsync(
+ long userId, long clubMembershipId,
+ long oldPackageId, long newPackageId,
+ CancellationToken ct)
+{
+ // 1. فیچرهای فعال فعلی کاربر
+ var currentFeatureIds = await _context.UserClubFeatures
+ .Where(ucf => ucf.UserId == userId && ucf.IsActive)
+ .Select(ucf => ucf.ClubFeatureId)
+ .ToListAsync(ct);
+
+ // 2. فیچرهای پکیج جدید
+ var newFeatureIds = await _context.PackageFeatures
+ .Where(pf => pf.PackageId == newPackageId && pf.IsIncluded)
+ .Select(pf => pf.ClubFeatureId)
+ .ToListAsync(ct);
+
+ // 3. DIFF
+ var toAdd = newFeatureIds.Except(currentFeatureIds).ToList();
+ var toRemove = currentFeatureIds.Except(newFeatureIds).ToList();
+
+ // 4. حذف فیچرهای قدیمی
+ if (toRemove.Any())
+ {
+ var featuresToDeactivate = await _context.UserClubFeatures
+ .Where(ucf => ucf.UserId == userId
+ && ucf.IsActive
+ && toRemove.Contains(ucf.ClubFeatureId))
+ .ToListAsync(ct);
+
+ foreach (var f in featuresToDeactivate)
+ {
+ f.IsActive = false;
+ f.Notes = $"حذف شده بابت تغییر پکیج (PackageId: {oldPackageId} → {newPackageId})";
+ }
+ }
+
+ // 5. اضافه فیچرهای جدید
+ if (toAdd.Any())
+ {
+ var newFeatures = toAdd.Select(featureId => new UserClubFeature
+ {
+ UserId = userId,
+ ClubMembershipId = clubMembershipId,
+ ClubFeatureId = featureId,
+ GrantedAt = DateTime.Now,
+ IsActive = true,
+ Notes = $"اضافه شده بابت خرید پکیج (PackageId: {newPackageId})"
+ }).ToList();
+
+ _context.UserClubFeatures.AddRange(newFeatures);
+ }
+
+ await _context.SaveChangesAsync(ct);
+
+ _logger.LogInformation(
+ "Feature DIFF applied for UserId={UserId}: Added={Added}, Removed={Removed}, Kept={Kept}",
+ userId, toAdd.Count, toRemove.Count,
+ currentFeatureIds.Count - toRemove.Count);
+}
+```
+
+> **نکته مهم:** در اولین خرید (`currentFeatures` خالی)، تمام فیچرهای پکیج اضافه میشوند.
+> در خرید مجدد **همان پکیج**، DIFF خالی است و هیچ تغییری در فیچرها نمیشود.
+
+### ۵.۶ تشخیص فعالشدگان هفته (v5 — Q22)
+
+```sql
+-- کاربرانی که در هفته جاری فعال/خرید مجدد کردهاند:
+SELECT cm."UserId", cm."LastPackageId", cm."LastActivationDate",
+ p."Title" AS PackageTitle
+FROM "CMS"."ClubMemberships" cm
+JOIN "CMS"."Packages" p ON p."Id" = cm."LastPackageId"
+WHERE cm."IsActive" = true
+ AND cm."LastActivationDate" >= @WeekStartDate
+ AND cm."LastActivationDate" < @WeekEndDate;
+
+-- اولین فعالسازی (عمر کاربر):
+SELECT cm."FirstActivationDate", cm."FirstPackageId"
+FROM "CMS"."ClubMemberships" cm
+WHERE cm."UserId" = @UserId;
+
+-- تعداد خرید (از تاریخچه):
+SELECT COUNT(*) AS TotalPurchases
+FROM "CMS"."ClubMembershipCycles" c
+WHERE c."UserId" = @UserId;
+```
---
@@ -307,16 +559,21 @@ Commission:
Reza → پاداش از Pool_پایه
```
-### ۶.۳ فلوی کامل چرخه خرید مجدد و تاثیر بر پورسانت (v3)
+### ۶.۳ فلوی کامل چرخه خرید مجدد و تاثیر بر پورسانت (v5)
```
-چرخه ۱ — کاربر "علی" پکیج پایه میخرد (۵۶M):
-────────────────────────────────────────────────
+چرخه ۱ — کاربر "علی" پکیج پایه میخرد (۵۶M) — اولین خرید:
+────────────────────────────────────────────────────────────────
✅ Balance += 56M
✅ DiscountBalance += 112M (×2)
✅ ActivationFee → Pool_پایه هفته جاری
- ✅ فیچرهای پکیج پایه فعال
- ✅ NetworkWeeklyBalance ساخته میشود (PackageId=پایه)
+ ✅ OTP + امضای قرارداد (فقط این بار — Q19)
+ ✅ فیچرهای پکیج پایه فعال (DIFF = همه اضافه — لیست خالی بود)
+ ✅ ClubMembership:
+ FirstActivationDate = now, FirstPackageId = پایه
+ LastActivationDate = now, LastPackageId = پایه
+ ✅ ClubMembershipCycle #1 ساخته میشود
+ ✅ NetworkWeeklyBalance (PackageId=پایه, WeekId=هفته جاری)
✅ بالاسریها: تعادلها بر اساس MaxBalancesPerLeg=300 + MaxNetworkLevel=15
✅ پورسانت بالاسریها از Pool_پایه
@@ -325,11 +582,16 @@ Commission:
چرخه ۲ — کاربر "علی" دوباره پکیج پایه میخرد:
──────────────────────────────────────────────
- ✅ ریست وضعیت: PackagePurchaseMethod=None, membership.IsActive=false
+ ✅ ریست وضعیت: PackagePurchaseMethod=None (membership فعال باقی — Q19)
✅ Balance += 56M (مجدد شارژ)
✅ DiscountBalance += 112M (مجدد شارژ)
✅ ActivationFee → Pool_پایه هفته جاری
- ✅ فیچرهای پکیج پایه فعال (مجدد)
+ ❌ بدون قرارداد مجدد (Q19 — قبلاً امضا شده)
+ ✅ فیچر DIFF = خالی (همان پکیج → بدون تغییر فیچر — Q20)
+ ✅ ClubMembership:
+ FirstActivationDate = حفظ, FirstPackageId = حفظ
+ LastActivationDate = now (بروزرسانی), LastPackageId = پایه
+ ✅ ClubMembershipCycle #2 ساخته میشود
✅ NetworkWeeklyBalance جدید (PackageId=پایه, WeekId=هفته جاری)
✅ carryover از هفته قبل: فقط carryover پکیج پایه (نه نقرهای!)
✅ بالاسریها: محاسبه مجدد از Pool_پایه
@@ -341,18 +603,30 @@ Commission:
✅ Balance += 5.6M
✅ DiscountBalance += 11.2M (×2)
✅ ActivationFee → Pool_نقرهای هفته جاری (۲,۵۲۰,۰۰۰)
- ✅ فیچرهای پکیج نقرهای فعال (ممکنه کمتر از پایه باشه!)
+ ❌ بدون قرارداد مجدد (Q19)
+ ✅ فیچر DIFF (Q20):
+ فعلی: [چاتیکا, بیمه, سفر, آموزش] (از پکیج پایه)
+ جدید: [چاتیکا, بیمه] (از پکیج نقرهای)
+ → حذف: [سفر, آموزش] → IsActive=false
+ → اضافه: [] → هیچی
+ → حفظ: [چاتیکا, بیمه]
+ ✅ ClubMembership:
+ FirstActivationDate = حفظ, FirstPackageId = حفظ (پایه)
+ LastActivationDate = now (بروزرسانی), LastPackageId = نقرهای
+ ✅ ClubMembershipCycle #5 ساخته میشود (PackageId=نقرهای)
✅ NetworkWeeklyBalance جدید (PackageId=نقرهای, WeekId=هفته جاری)
✅ تعادلها: MaxBalancesPerLeg=30 (نه 300!) + MaxNetworkLevel=15 (از پکیج)
- ✅ carryover: فقط carryover نقرهای (جداگانه از پایه)
+ ✅ carryover: فقط carryover نقرهای (Q23 — carryover پکیج پایه شمرده نمیشود!)
✅ بالاسریها: محاسبه از Pool_نقرهای → ValuePerBalance کمتر
✅ پاداش بالاسری: ~۲,۵۲۰,۰۰۰ ÷ TotalBalances_نقرهای × BalancesEarned
```
-### ۶.۴ تعادلها (NetworkWeeklyBalance) — per-package (v3)
+### ۶.۴ تعادلها (NetworkWeeklyBalance) — per-package (v5)
> ⚠️ **تغییر اساسی:** هر کاربر **بهازای هر پکیج فعال** یک رکورد تعادل جداگانه دارد.
+> 🔴 **قانون carryover v5 (Q23):** اگر کاربر **پکیج عوض کرد**، carryover پکیج قبلی **شمرده نمیشود!**
+
```
قبل (تکپکیج):
NetworkWeeklyBalance: [UserId, WeekId] → یک رکورد
@@ -365,9 +639,15 @@ Commission:
```
برای هر پکیج فعال:
- 1. واکشی کاربرانی که این پکیج را دارند (PackageId = X)
- 2. carryover از هفته قبل: فقط رکوردهای PackageId = X
- 3. اعضای جدید: فقط کسانی که PackageId = X خریدند + JoinedAt در بازه هفته
+ 1. واکشی کاربرانی که این پکیج را دارند:
+ ClubMembership.LastPackageId = X (v5 — Q21)
+ 2. carryover از هفته قبل:
+ فقط رکوردهای PackageId = X
+ ✅ مهم: فقط اگر پکیج فعلی کاربر = X (Q23)
+ ❌ اگر کاربر هفته قبل پکیج Y داشت و حالا X دارد → carryover Y شمرده نمیشود!
+ 3. اعضای جدید این هفته:
+ ClubMembership.LastActivationDate در بازه هفته
+ AND ClubMembership.LastPackageId = X (v5 — Q22)
4. LeftLegTotal = NewLeft + CarryoverLeft
5. RightLegTotal = NewRight + CarryoverRight
6. TotalBalances = MIN(Left, Right) → cap at Package.MaxBalancesPerLeg
@@ -375,6 +655,22 @@ Commission:
8. SubordinateBalances: مجموع TotalBalances زیرمجموعه (تا Package.MaxNetworkLevel)
```
+**مثال carryover با تغییر پکیج (v5 — Q23):**
+
+```
+هفته ۹:
+ علی: پکیج پایه → تعادل_پایه: Left=45, Right=30
+ carryover_پایه: {Left: 15, Right: 0} ← باقیمانده
+
+هفته ۱۰:
+ علی: پکیج پایه → EXIT Magic → خرید پکیج نقرهای
+
+ ❌ carryover_پایه {Left:15, Right:0} شمرده نمیشود!
+ (پکیج فعلی = نقرهای ≠ پایه)
+ ✅ carryover_نقرهای: {Left:0, Right:0} (تازه شروع)
+ ✅ تعادل_نقرهای: Left=NewLeft+0, Right=NewRight+0
+```
+
**مثال عملی:**
```
@@ -584,7 +880,7 @@ flowchart LR
| `PackageCreated` | ساخت WeeklyCommissionPool خالی هفته جاری |
| `PackageDeactivated` | هشدار ادمین — Pool موجود تکمیل شود |
| `PackagePurchased` | ActivationFee → Pool پکیج + شارژ wallets |
-| `MagicCycleCompleted` | ریست PackagePurchaseMethod + Deactivate membership |
+| `MagicCycleCompleted` | ریست PackagePurchaseMethod + حفظ membership فعال (Q19) |
---
@@ -689,25 +985,32 @@ claims.Add("CanRepurchase", HasCompletedMagicCycle(membership).ToString());
---
-### ۷٫۵٫۵ Club Features — تخصیص global
+### ۷٫۵٫۵ Club Features — تخصیص global → DIFF (v5)
-> 🔴 **بحرانی — همه فیچرها به همه کاربران**
+> 🔴 **بحرانی — فیچرها باید DIFF/تفاضل باشند (Q20)**
```csharp
-// ActivateClubMembershipCommandHandler.cs + AcceptClubMembershipContractCommandHandler.cs:
+// فعلی (اشتباه) — ActivateClubMembershipCommandHandler.cs + AcceptClubMembershipContractCommandHandler.cs:
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
// ↑ همیشه همه فیچرها (Chatika, Bime, Trip, Learn) — صرفنظر از پکیج!
```
-**مشکل:** پکیج نقرهای ممکنه فقط ۲ فیچر داشته باشه ولی سیستم فعلی **همه ۴ فیچر** رو فعال میکنه.
+**مشکل ۱:** پکیج نقرهای ممکنه فقط ۲ فیچر داشته باشه ولی سیستم فعلی **همه ۴ فیچر** رو فعال میکنه.
+**مشکل ۲:** در خرید مجدد، باید فقط **تفاضل** اعمال بشه (Q20) — نه تخصیص مجدد همه.
-**اقدام:**
+**اقدام (v5):**
```csharp
-// باید بشه:
-var featureIds = await _context.PackageFeatures
- .Where(pf => pf.PackageId == package.Id && pf.IsIncluded)
- .Select(pf => pf.ClubFeatureId)
- .ToListAsync();
+// خرید اول (currentFeatures خالی → همه فیچرها اضافه):
+await _featureDiffService.ApplyFeatureDiffAsync(
+ userId, membershipId,
+ oldPackageId: 0, // بدون پکیج قبلی
+ newPackageId: package.Id);
+
+// خرید مجدد (مقایسه + تفاضل):
+await _featureDiffService.ApplyFeatureDiffAsync(
+ userId, membershipId,
+ oldPackageId: membership.LastPackageId,
+ newPackageId: newPackage.Id);
```
---
@@ -873,9 +1176,14 @@ BEGIN
SELECT "Id" INTO base_pkg_id
FROM "CMS"."Packages" WHERE "IsBasePackage" = true LIMIT 1;
- -- STEP 2: ClubMembership
+ -- STEP 2: ClubMembership — v5 migration (Q21)
+ -- ActivatedAt → FirstActivationDate + LastActivationDate
UPDATE "CMS"."ClubMemberships"
- SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL;
+ SET "FirstActivationDate" = "ActivatedAt",
+ "LastActivationDate" = "ActivatedAt",
+ "FirstPackageId" = base_pkg_id,
+ "LastPackageId" = base_pkg_id
+ WHERE "FirstActivationDate" IS NULL;
-- STEP 3: ClubMembershipCycle
UPDATE "CMS"."ClubMembershipCycles"
@@ -897,7 +1205,7 @@ BEGIN
END $$;
-- STEP 7: Verify — همه باید 0 باشند
-SELECT 'ClubMemberships' AS tbl, COUNT(*) FROM "CMS"."ClubMemberships" WHERE "PackageId" IS NULL
+SELECT 'ClubMemberships' AS tbl, COUNT(*) FROM "CMS"."ClubMemberships" WHERE "LastPackageId" IS NULL
UNION ALL
SELECT 'Cycles', COUNT(*) FROM "CMS"."ClubMembershipCycles" WHERE "PackageId" IS NULL
UNION ALL
@@ -910,9 +1218,9 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId"
---
-## ۱۱. Impact Analysis — ۹۰+ تغییر در ۶ لایه (v4)
+## ۱۱. Impact Analysis — ۹۵+ تغییر در ۶ لایه (v5)
-> ۴۸ تغییر اصلی (بخش ۱۰) + ۴۴ سایدافکت (بخش ۷.۵) = **۹۲ تغییر کل**
+> ۵۱ تغییر اصلی (بخش ۱۰) + ۴۴ سایدافکت (بخش ۷.۵) = **۹۵ تغییر کل**
### ۱۰.۱ لایه Domain (۱۰ تغییر)
@@ -920,7 +1228,7 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId"
|---|------|-----|------|-----|
| D1 | `Package.cs` | اضافه **۱۱ فیلد** جدید (v2: ۷ + v3: ۴) | 🟡 | 🔄 |
| D2 | `PackageFeature.cs` | Entity جدید + EF Config | 🔴 | |
-| D3 | `ClubMembership.cs` | اضافه `PackageId` | 🟡 | |
+| D3 | `ClubMembership.cs` | حذف `ActivatedAt` → اضافه **۴ فیلد**: `FirstActivationDate`, `FirstPackageId`, `LastActivationDate`, `LastPackageId` (v5 — Q21) | 🔴 | 🔄 |
| D4 | `ClubMembershipCycle.cs` | اضافه `PackageId` | 🟡 | |
| D5 | `WeeklyCommissionPool.cs` | اضافه `PackageId` + Unique | 🔴 | |
| D6 | `UserCommissionPayout.cs` | اضافه `PackageId` + Unique(UserId,WeekId,PackageId) | 🟡 | 🔄 |
@@ -943,17 +1251,19 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId"
| A7 | `PurchasePackageCommandHandler` | اجازه re-purchase | 🟡 |
| A8 | `CreateManualPaymentCommandHandler` | DiscountMultiplier از Package | 🟡 |
| A9 | `CheckAndProcessDayaLoansCommandHandler` | حذف ID=4 + DiscountMultiplier | 🟡 |
-| A10 | `AcceptClubMembershipContractCommandHandler` | اجازه re-contract بعد چرخه | 🟡 |
+| A10 | `AcceptClubMembershipContractCommandHandler` | حفظ guard (قرارداد یکبار — Q19). خرید مجدد Skip قرارداد | 🟡 |
| A11 | `UserOrderService` (EXIT Magic) | ریست PackagePurchaseMethod + Deactivate | 🔴 |
| A12 | Package CRUD handlers | فیلدهای جدید + PackageFeature CRUD | 🟡 |
| **A13** | **`ChargeMagicWalletCommandHandler`** | **`MagicWalletMaxDeposit` از Package بخوند (نه global)** | **🔴 v4** |
| **A14** | **`VerifyMagicWalletChargeCommandHandler`** | **`MagicWalletMultiplier` از Package بخوند (نه ×2.5 global)** | **🔴 v4** |
| **A15** | **`UserOrderService.cs` EXIT/ENTRY** | **EXIT: سقف از Package + ENTRY: چک کدام پکیج** | **🔴 v4** |
| **A16** | **Validators (۳ فایل)** | **`1_000_000_000` hardcoded → داینامیک per-package** | **🟡 v4** |
-| **A17** | **`GetAllFeatureIds()` (۲ handler)** | **همه فیچرها global → per-package از PackageFeature** | **🔴 v4** |
+| **A17** | **`GetAllFeatureIds()` (۲ handler)** | **فیچر DIFF/تفاضل (Q20): مقایسه فعلی vs جدید، فقط اختلاف اعمال** | **🔴 v5** |
| **A18** | **JWT Token Generation** | **اضافه PackageId + PackageTitle + CanRepurchase** | **🟡 v4** |
| **A19** | **`WalletGrpcService.GetMagicWalletStatus`** | **سقف و باقیمانده per-package (نه global)** | **🟡 v4** |
| **A20** | **Notifications (۴ مورد)** | **اضافه PackageId + PackageTitle به interface** | **🟡 v4** |
+| **A21** | **`FeatureDiffService` — جدید** | **سرویس DIFF فیچرها (Q20): مقایسه + اعمال تفاضل** | **🔴 v5** |
+| **A22** | **`ActivateClubMembership` — FirstLast** | **بروزرسانی First/LastActivationDate + First/LastPackageId (Q21)** | **🟡 v5** |
### ۱۰.۳ لایه Infrastructure (۶ تغییر)
@@ -1029,7 +1339,7 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId"
|-----|----- |-----|
| **T1.1** | بروزرسانی `Package` entity (**۱۱ فیلد** جدید: v2 ۷ + v3 ۴ شامل MaxBalancesPerLeg, MaxNetworkLevel, MagicWalletMaxDeposit, MagicWalletMaxCredit) | 🔄 |
| **T1.2** | ایجاد `PackageFeature` entity + EF Config | |
-| **T1.3** | اضافه `PackageId` به `ClubMembership` | |
+| **T1.3** | حذف `ActivatedAt` → اضافه **۴ فیلد**: `FirstActivationDate`, `FirstPackageId`, `LastActivationDate`, `LastPackageId` (Q21) | 🔄 v5 |
| **T1.4** | اضافه `PackageId` به `ClubMembershipCycle` | |
| **T1.5** | اضافه `PackageId` به `WeeklyCommissionPool` + Unique(WeekId,PackageId) | |
| **T1.6** | اضافه `PackageId` به `UserCommissionPayout` + Unique(UserId,WeekId,PackageId) | 🔄 |
@@ -1047,11 +1357,11 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId"
|-----|----- |-----|
| **T2.1** | ادغام Verify handlers → Generic (DiscountMultiplier + UserPackagePurchase) | |
| **T2.2** | ادغام Purchase handlers → Generic (حذف "طلایی"، حذف ID=4) | |
-| **T2.3** | بروزرسانی `ActivateClubMembership` — فیچر از PackageFeature | |
+| **T2.3** | بروزرسانی `ActivateClubMembership` — فیچر DIFF از PackageFeature (Q20) + First/LastActivation (Q21) | 🔄 v5 |
| **T2.4** | بروزرسانی `ActivateClubMembership` — ActivationFee از Package | |
| **T2.5** | اجازه re-purchase در Guards (G1–G3) | |
-| **T2.6** | ریست وضعیت در EXIT Magic Mode | |
-| **T2.7** | اجازه re-contract (G5) + بروزرسانی JWT (G7) | |
+| **T2.6** | ریست وضعیت در EXIT Magic Mode (بدون `IsActive=false` — Q19) | |
+| **T2.7** | حفظ guard قرارداد (Q19) + ایجاد `FeatureDiffService` (Q20) + بروزرسانی JWT (G7) | 🔄 v5 |
| **T2.8** | بروزرسانی `CreateManualPayment` + `DayaLoan` | |
| **T2.9** | PackageFeature CRUD | |
| **T2.10** | Event: PackageCreated → ساخت Pool خالی | |
@@ -1061,6 +1371,8 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId"
| **T2.14** | **`WalletGrpcService.GetMagicWalletStatus`: سقف per-package** | **🆕 v4** |
| **T2.15** | **Notifications (۴ مورد): اضافه PackageId به interface + پیام** | **🆕 v4** |
| **T2.16** | **بروزرسانی ۲۱ محل ساخت WalletChangeLog با PackageId** | **🆕 v4** |
+| **T2.17** | **ایجاد `FeatureDiffService` — سرویس DIFF فیچرها (Q20) + استفاده در Activate + AcceptContract** | **🆕 v5** |
+| **T2.18** | **بروزرسانی فلوی خرید مجدد — Skip قرارداد (Q19) + فقط FeatureDiff + شارژ wallet** | **🆕 v5** |
### فاز ۳ — محاسبه پورسانت ≈ ۴ روز (موازی با فاز ۲)
@@ -1112,11 +1424,15 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId"
| **T5.9** | **تست ضریب جادویی: پکیج A ×2.5 vs پکیج B ×2.0 — اعتبار صحیح** | **🆕 v4** |
| **T5.10** | **تست قرارداد حقوقی: مبلغ و نام پکیج صحیح در متن** | **🆕 v4** |
| **T5.11** | **تست WalletChangeLog: رکوردها PackageId دارند** | **🆕 v4** |
-| **T5.12** | Deploy staging → production |
+| **T5.12** | **تست قرارداد یکبار: خرید مجدد بدون OTP/قرارداد (Q19)** | **🆕 v5** |
+| **T5.13** | **تست فیچر DIFF: تغییر پکیج → فیچرهای صحیح فعال/غیرفعال (Q20)** | **🆕 v5** |
+| **T5.14** | **تست First/Last: FirstActivationDate حفظ + LastActivationDate بروزرسانی (Q21)** | **🆕 v5** |
+| **T5.15** | **تست carryover تغییر پکیج: carryover قبلی شمرده نمیشود (Q23)** | **🆕 v5** |
+| **T5.16** | Deploy staging → production |
---
-## ۱۲. ریسکها (v4)
+## ۱۲. ریسکها (v5)
| ریسک | احتمال | شدت | راهحل |
|------|--------|-----|--------|
@@ -1128,21 +1444,24 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId"
| **Magic Wallet EXIT اشتباه — کاربر گیر میافته** | **زیاد** | **بحرانی** | **اولویت P0 — سقف از Package خوانده شود** |
| **قرارداد حقوقی با مبلغ اشتباه** | **زیاد** | **بحرانی** | **متن قرارداد داینامیک از Package** |
| **۲۱ WalletChangeLog بدون ردیابی** | **قطعی** | **متوسط** | **اضافه PackageId به entity** |
+| **فیچر DIFF — حذف فیچر فعال (v5)** | **متوسط** | **بالا** | **کاربر اگر فیچر فعالی حذف بشه، باید اطلاعرسانی بشه** |
+| **Migration ActivatedAt → First/Last (v5)** | **کم** | **بحرانی** | **هر دو فیلد = ActivatedAt فعلی، بعداً Last بروزرسانی** |
+| **ناسازگاری AcceptContract vs Activate (v5)** | **زیاد** | **بالا** | **AcceptContract: overwrite ActivatedAt / Activate: حفظ — باید یکسان شوند** |
---
-## ۱۳. تخمین زمانی (v4)
+## ۱۳. تخمین زمانی (v5)
-| فاز | مدت | وابستگی | v4 تغییر |
+| فاز | مدت | وابستگی | v5 تغییر |
|-----|------|---------|----------|
| فاز ۰ — فیکس باگها | ۱ روز | — | |
-| فاز ۱ — زیرساخت | **۵ روز** | فاز ۰ | +۱ (WalletChangeLog PackageId + ۲۱ محل) |
-| فاز ۲ — منطق | **۵ روز** | فاز ۱ | +۱ (Magic Wallet + Validators + JWT + Notifications) |
-| فاز ۳ — پورسانت | ۴ روز | فاز ۱ | |
-| فاز ۴ — UI | **۷ روز** | فاز ۲ | +۲ (Magic Wallet UI + قرارداد + ManualActivation + SystemConfig) |
-| فاز ۵ — تست | **۴ روز** | فاز ۳, ۴ | +۱ (تست Magic per-package + قرارداد + WalletLog) |
-| **مجموع** | **~۲۶ روز** | | |
+| فاز ۱ — زیرساخت | **۵ روز** | فاز ۰ | ClubMembership: ۴ فیلد First/Last (Q21) + Migration ActivatedAt |
+| فاز ۲ — منطق | **۶ روز** | فاز ۱ | +۱ (FeatureDiffService + Skip قرارداد + First/Last Activation) |
+| فاز ۳ — پورسانت | ۴ روز | فاز ۱ | LastActivationDate برای تشخیص هفتگی (Q22) |
+| فاز ۴ — UI | **۷ روز** | فاز ۲ | |
+| فاز ۵ — تست | **۵ روز** | فاز ۳, ۴ | +۱ (تست قرارداد یکبار + فیچر DIFF + First/Last + carryover تغییر پکیج) |
+| **مجموع** | **~۲۸ روز** | | |
-> فاز ۲ و ۳ **موازی** → مسیر بحرانی: ۰→۱→۲→۴→۵ = **~۲۲ روز**
-> نسبت به v3 (**۱۷ روز**): **+۵ روز** بخاطر ۴۴ سایدافکت کشفشده
-> نسبت به v2 (**۱۴ روز**): **+۸ روز** — بزرگترین سهم: Magic Wallet + WalletChangeLog + قرارداد حقوقی
+> فاز ۲ و ۳ **موازی** → مسیر بحرانی: ۰→۱→۲→۴→۵ = **~۲۴ روز**
+> نسبت به v4 (**۲۲ روز**): **+۲ روز** بخاطر FeatureDiffService + قرارداد یکبار + First/Last Activation
+> نسبت به v3 (**۱۷ روز**): **+۷ روز** — بزرگترین سهم: Magic Wallet + WalletChangeLog + فیچر DIFF + First/Last