Files
docs/business/BIZ-PACKAGE-BASED-SYSTEM.md
T
masoodafar-web 39590d2cbe docs: Phase 10 — DataMigration + EF Staging + PackagePurchaseDialog + UI Fixes
Updated 8 docs:
- CHANGELOG: Phase 10a-d (DataMigration tool, EF staging migrations, PackagePurchaseDialog, 4 UI fixes)
- PAYMENT-FINANCE: Rial→Toman conversion chain documented, PackagePurchaseDialog status
- TECH-02: Added PackagePurchaseDialog to folder structure + status table
- TECH-04: DataMigration tool features (smart retry, FK handling, fallback tables), EF staging
- PACKAGE-TASKS: Phase 10 graph, NuGet v0.0.189, T4.1+T4.2 marked 
- BIZ-PACKAGE: v6→v7, commits updated, T4.1+T4.2 marked 
- INDEX: Updated last-update + R3 description
- ROADMAP: Progress bars updated, DONE section + NOW section refreshed
2026-02-27 20:56:00 +03:30

1785 lines
95 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 📦 سیستم مبتنی بر پکیج (Package-Based System)
> **وضعیت:** پیاده‌سازی کد تکمیل — **فاز ۰-۱۰ ✅** | تست نهایی باقی‌مانده
> **تاریخ بروزرسانی:** ۸ اسفند ۱۴۰۴
> **نسخه:** v7 (DataMigration + EF Staging + PackagePurchaseDialog + UI Fixes)
> **تاثیرگذاری:** زیاد — **۱۰۰+** تغییر در ۶ لایه (۵۱ اصلی + ۴۴ سایدافکت + ۵ استقرار)
> **کامیت‌ها:** `8b9c317` (Phase 0) → `ae92ab8` (Phase 1) → `a9cd2fd` (Phase 1.5) → `8e5c7c5` (Phase 2) → `ccb938e` (Phase 3) → `0002a5a` (Phase 4) → `a1024a3` (Q24+Q26) → `fdbb91d` (Q27) → `10d2ca2` (Rename+Interceptor+Migration) → `a3681a8` (PackagePurchaseDialog) → `3c1a8ff` (UI Fixes)
---
## ۱. خلاصه فیچر
**وضعیت فعلی:** سیستم فقط یک پکیج پایه (۵۶ میلیون تومان) دارد و همه چیز حول آن hardcode شده.
**وضعیت هدف:** سیستم چندین پکیج با قیمت‌ها و ویژگی‌های متفاوت پشتیبانی می‌کند. هر پکیج مقادیر مالی، فیچرها و Commission Pool مستقل خود را دارد. کاربر می‌تواند **N بار** پکیج بخرد (بعد از تکمیل چرخه Magic Wallet).
---
## ۲. تصمیمات تایید‌شده
| # | سوال | تصمیم |
|---|------|-------|
| Q1 | باگ DiscountBalance در VerifyGoldenPackagePurchase | ✅ **باگه — باید فیکس بشه** |
| Q2 | سه مسیر پرداخت موازی | ✅ **ادغام به سرویس Generic** — نه به نام پکیج خاص |
| Q3 | پکیج‌های اولیه | ✅ **نقره‌ای (۵.۶M) + پایه (۵۶M)** — سیستم داینامیک |
| Q4 | ActivationFee و GiftValue | ✅ **یک فیلد (ActivationFee)** — GiftValue حذف (تکراری بود) |
| Q5 | DiscountMultiplier | ✅ **×2 برای همه** — ولی داینامیک در entity |
| Q6 | Migration کاربران فعلی | ✅ **Pipeline/Script** — کاربران فعلی → PackageId = پکیج پایه |
| Q7 | خرید چند پکیج | ✅ **N بار** — بعد تکمیل Magic Wallet و صفر شدن Balance |
| Q8 | Commission Pool | ✅ **جدا برای هر پکیج** — تمام جداول Commission با PackageId |
| Q9 | MagicWallet Multiplier | ✅ **داینامیک** به‌ازای هر پکیج (فعلاً همه ×2.5) |
| Q10 | کاربران دایا | ✅ **پکیج پایه** گرفتن — "طلایی" اشتباه نام‌گذاری بوده |
| Q11 | فیچرها | ✅ **داینامیک** — ادمین مدیریت می‌کند |
| Q12 | MaxWeeklyBalancesPerLeg (300) | ✅ **per-package** — پکیج نقره‌ای ۳۰، پکیج پایه ۳۰۰ |
| Q13 | MaxNetworkLevel (15) | ✅ **per-package** — ادمین تنظیم کند |
| Q14 | MagicWalletMaxDeposit (1B) | ✅ **per-package** — سقف شارژ بر اساس پکیج |
| Q15 | MagicWalletMaxCredit (2.5B) | ✅ **per-package** — سقف اعتبار بر اساس پکیج |
| 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 تعادل هفتگی | ✅ **per-downline-package** — تعادل بر اساس پکیج **زیرمجموعه‌ها** (نه پکیج خود کاربر). هر کاربر N رکورد تعادل دارد. تغییر پکیج خود کاربر تاثیری بر carryover ندارد |
| Q24 | آستانه موجودی برای ورود Magic و خرید مجدد | ✅ **کمتر از ۱۰۰,۰۰۰ تومان** — چون قیمت محصولات متفاوته، `Balance == 0` عملاً غیرممکنه. آستانه ثابت ۱,۰۰۰,۰۰۰ ریال |
| Q25 | DayaLoans محدودیت پکیج | ✅ **فقط پکیج پایه**`SupportsDayaPurchase` فقط روی پکیج پایه `true` هست. تغییر نمی‌کنه |
| Q26 | مدیریت Stored Procedures | ✅ **SP Worker (IHostedService)** — در startup، فایل‌های `.sql` از embedded resource خوانده و با checksum مقایسه و اعمال می‌شوند |
| Q27 | History Tables یکسان‌سازی | ✅ **نام‌گذاری مشابه master** + ثبت خودکار تغییرات در EF interceptor/domain events |
| Q28 | UI Guidance (آموزش/هشدار) | ✅ **مودال + متن inline** — در سراسر FO/BO توضیحات آموزشی و هشداری برای سیستم پکیج‌بیس |
| Q29 | شرط EXIT Magic | ✅ **آخرین پکیج فعال** — از `ClubMembershipCycle.PackageId` (چرخه فعلی) → `Package.MagicWalletMaxDeposit` |
| Q30 | Carryover توضیح | ✅ **باقی‌مانده تعادل هفتگی** — بر اساس پکیج **زیرمجموعه‌ها**. تغییر پکیج خود کاربر carryover را ریست **نمی‌کند**. هر Pool مجزا |
---
## ۳. تحلیل عمیق وضعیت فعلی (AS-IS)
### ۳.۱ باگ‌های کشف‌شده
| # | باگ | شدت | فایل |
|---|-----|------|------|
| **B1** | `VerifyGoldenPackagePurchase`**DiscountBalance شارژ نمی‌شود** | 🔴 بحرانی | VerifyGoldenPackagePurchaseCommandHandler.cs |
| **B2** | `VerifyGoldenPackagePurchase`**UserPackagePurchase ساخته نمی‌شود** | 🔴 بحرانی | VerifyGoldenPackagePurchaseCommandHandler.cs |
| **B3** | `VerifyPackagePurchase`**UserPackagePurchase ساخته نمی‌شود** | 🔴 بحرانی | VerifyPackagePurchaseCommandHandler.cs |
| **B4** | `VerifyBasePackagePayment`**UserPackagePurchase ساخته نمی‌شود** | 🔴 بحرانی | VerifyBasePackagePaymentCommandHandler.cs |
| **B5** | `PackageService.CustomerPurchasePackage`**guard برای خرید تکراری ندارد** | 🟡 متوسط | PackageService.cs |
| **B6** | EXIT Magic Mode → **PackagePurchaseMethod ریست نمی‌شود** (خرید مجدد مسدود) | 🔴 بحرانی | UserOrderService.cs |
### ۳.۲ ناسازگاری مسیرهای پرداخت
| مسیر | Balance | Discount | UserPackagePurchase | WalletChangeLog |
|------|---------|----------|---------------------|-----------------|
| **BFF/PYMS** (InitiateBase→VerifyBase) | ✅ | ✅ | ❌ | ✅ |
| **ZarinPal Golden** (PurchaseGolden→VerifyGolden) | ✅ | ❌ | ❌ | ✅ (فقط Balance) |
| **ZarinPal Generic** (Purchase→VerifyPurchase) | ✅ | ✅ | ❌ | ✅ |
| **Daya Loan** (CheckAndProcess) | ✅ | ✅ | ✅ | ✅ |
| **Manual** (CreateManualPayment) | ✅ | ✅ | ❌ | ✅ |
> **فقط Daya Loan** همه مراحل را کامل انجام می‌دهد. بقیه مسیرها ناقص هستند.
### ۳.۳ مقادیر Hardcoded (۱۵ مورد)
| # | مکان | مقدار | باید بشه |
|---|------|-------|----------|
| H1 | `InitiateBasePackagePaymentCommandHandler` | `BasePackageId = 4` | خوانش از پکیج فعال |
| H2 | `CheckAndProcessDayaLoansCommandHandler` | `p.Id == 4` | خوانش از پکیج پایه |
| H3 | `SystemConstants.BasePackageAmount` | `56_000_000` | `Package.Price` |
| H4 | `SystemConstants.DayaLoanAmount` | `56_000_000` | `Package.Price` |
| H5 | `SystemConstants.ClubActivationFee` | `25_200_000` | `Package.ActivationFee` |
| H6 | `SystemConstants.ClubMembershipGiftValue` | `25_200_000` | حذف (= ActivationFee) |
| H7 | `VerifyPackagePurchaseCommandHandler` | `Amount * 2` (×3 جا) | `Package.DiscountMultiplier` |
| H8 | `VerifyBasePackagePaymentCommandHandler` | `BasePackageAmount * 2` (×2) | `Package.DiscountMultiplier` |
| H9 | `CreateManualPaymentCommandHandler` | `BasePackageAmount * 2` | `Package.DiscountMultiplier` |
| H10 | `CheckAndProcessDayaLoansCommandHandler` | `DayaLoanAmount * 2` | `Package.DiscountMultiplier` |
| H11 | `ActivateClubMembershipCommandHandler` | `PackageAmount = BasePackageAmount` | `Package.Price` |
| H12 | `PurchaseGoldenPackageCommandHandler` | `Title.Contains("طلایی")` | حذف — Generic |
| H13 | `ActivateClubMembershipCommandHandler` | `GetAllFeatureIds()` | `Package.PackageFeatures` |
| H14 | `ActivationSection.razor` | `56_000_000 × months` | از پکیج خوانده شود |
| H15 | `ClubMembershipContractDialog.razor` | متن قرارداد ۵۶M | داینامیک از پکیج |
### ۳.۴ Guardهای مسدودکننده خرید مجدد
| # | فایل | Guard | وضعیت | تغییر |
|---|------|-------|-------|-------|
| G1 | `PurchaseGoldenPackageCommandHandler` | `PackagePurchaseMethod != None` → throw | مسدود | ✅ اجازه بعد تکمیل چرخه |
| G2 | `InitiateBasePackagePaymentCommandHandler` | `PackagePurchaseMethod != None` → fail | مسدود | ✅ اجازه بعد تکمیل چرخه |
| G3 | `PurchasePackageCommandHandler` | `PackagePurchaseMethod != None` → throw | مسدود | ✅ اجازه بعد تکمیل چرخه |
| G4 | `CheckAndProcessDayaLoansCommandHandler` | `hasPreviousCycle` → skip | عمدی ✅ | ❌ حفظ (دایا فقط دور اول) |
| 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 — خرید مجدد کار نمی‌کند
```
EXIT Magic Mode (UserOrderService.cs):
✅ wallet.WalletMode = Normal
✅ wallet.MagicCompletedAt = now
✅ cycle.MagicCompletedAt = now
❌ MISSING: user.PackagePurchaseMethod = None ← Guards G1-G3 مسدود می‌مانند
❌ MISSING: cycle.IsCurrentCycle = false ← آماده چرخه جدید
❌ MISSING: JWT CanRepurchase = true ← UI دکمه خرید نشان نمی‌دهد
️ membership.IsActive حفظ می‌شود (قرارداد یک‌بار — Q19)
ℹ️ فیچرها حفظ می‌شوند — در خرید مجدد DIFF اعمال می‌شود (Q20)
```
**راه‌حل:** در EXIT Magic Mode، وضعیت کاربر ریست شود تا بتواند پکیج جدید بخرد.
---
## ۴. طراحی نهایی (TO-BE)
### ۴.۱ تغییرات Entity — Package
```csharp
public class Package : BaseAuditableEntity
{
// === فیلدهای فعلی (حفظ) ===
public string Title { get; set; }
public string Description { get; set; }
public string ImagePath { get; set; }
public long Price { get; set; } // قیمت پکیج (ریال)
// === فیلدهای جدید ===
public int SortOrder { get; set; } // ترتیب نمایش
public bool IsActive { get; set; } = true; // فعال/غیرفعال
public bool IsBasePackage { get; set; } // پکیج پایه؟ (فقط یکی true)
public bool SupportsDayaPurchase { get; set; } // پشتیبانی از خرید دایا
public bool SupportsDirectPurchase { get; set; } = true; // پشتیبانی از پرداخت مستقیم
// === محاسبات مالی ===
public long ActivationFee { get; set; } // سهم Commission Pool
public decimal DiscountMultiplier { get; set; } = 2.0m; // ضریب شارژ DiscountBalance
public decimal MagicWalletMultiplier { get; set; } = 2.5m; // ضریب کیف‌پول جادویی
// === تنظیمات پورسانت (v3 — انتقال از SystemConstants) ===
public int MaxBalancesPerLeg { get; set; } = 300; // سقف تعادل هر پا (نقره‌ای=۳۰)
public int MaxNetworkLevel { get; set; } = 15; // عمق شبکه برای محاسبه
// === تنظیمات کیف‌پول جادویی (v3) ===
public long MagicWalletMaxDeposit { get; set; } = 1_000_000_000; // سقف شارژ
public long MagicWalletMaxCredit { get; set; } = 2_500_000_000; // سقف اعتبار
// === Navigation ===
public virtual ICollection<PackageFeature> PackageFeatures { get; set; }
public virtual ICollection<UserPackagePurchase> Purchases { get; set; }
public virtual ICollection<UserOrder> UserOrders { get; set; }
}
```
**نسبت به v1:**
-`GiftValue` حذف (= ActivationFee — تکراری)
-`MagicWalletMultiplier` اضافه (داینامیک)
-`PackageType` enum نیاز نیست (`IsBasePackage` کافیست)
### ۴.۲ Entity جدید — PackageFeature
```csharp
public class PackageFeature : BaseAuditableEntity
{
public long PackageId { get; set; }
public virtual Package Package { get; set; }
public long ClubFeatureId { get; set; }
public virtual ClubFeature ClubFeature { get; set; }
public bool IsIncluded { get; set; } = true;
}
```
### ۴.۳ تغییرات Entity — سایر
| Entity | فیلد جدید | توضیح |
|--------|-----------|-------|
| `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) ===
/// <summary>
/// اولین فعال‌سازی — فقط یک بار ست می‌شود، هیچ‌وقت overwrite نمی‌شود
/// </summary>
public DateTime FirstActivationDate { get; set; }
/// <summary>
/// اولین پکیج خریداری‌شده — فقط یک بار ست می‌شود
/// </summary>
public long FirstPackageId { get; set; }
public virtual Package FirstPackage { get; set; }
/// <summary>
/// آخرین/جاری فعال‌سازی — هر خرید مجدد بروزرسانی می‌شود
/// مبنای تشخیص "فعال‌شدگان این هفته" (Q22)
/// </summary>
public DateTime LastActivationDate { get; set; }
/// <summary>
/// آخرین/جاری پکیج — هر خرید مجدد بروزرسانی می‌شود
/// مبنای carryover per-package (Q23)
/// </summary>
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<UserClubFeature>? UserClubFeatures { get; set; }
public virtual ICollection<ClubMembershipHistory>? ClubMembershipHistories { get; set; }
public virtual ICollection<ClubMembershipCycle>? Cycles { get; set; }
}
```
**Constraintهای جدید:**
- `WeeklyCommissionPool` → Unique(`WeekDefinitionId`, `PackageId`)
- `NetworkWeeklyBalance` → Unique(`UserId`, `WeekDefinitionId`, `PackageId`) ← v3
- `UserCommissionPayout` → Unique(`UserId`, `WeekDefinitionId`, `PackageId`) ← v3
> ⚠️ **تاثیر حجم داده:** رکوردهای `NetworkWeeklyBalance` ضربدر تعداد پکیج‌های فعال می‌شوند. مثلاً ۱۰۰۰ کاربر × ۲ پکیج = ۲۰۰۰ رکورد تعادل هفتگی (بجای ۱۰۰۰).
### ۴.۴ حذف/تغییر SystemConstants
| ثابت | تغییر | جایگزین |
|------|-------|---------|
| `ClubMembershipGiftValue` | ❌ حذف | تکراری بود |
| `ClubActivationFee` | ❌ حذف | `Package.ActivationFee` |
| `BasePackageAmount` | ❌ حذف | `Package.Price` |
| `DayaLoanAmount` | ❌ حذف | `Package.Price` (base) |
| `MagicWalletMultiplier` | ❌ حذف | `Package.MagicWalletMultiplier` |
| `CommissionMaxWeeklyBalancesPerLeg` | ❌ **حذف** ← تغییر از v2 | `Package.MaxBalancesPerLeg` (نقره‌ای=۳۰، پایه=۳۰۰) |
| `CommissionMaxNetworkLevel` | ❌ **حذف** ← تغییر از v2 | `Package.MaxNetworkLevel` (قابل تنظیم ادمین) |
| `MagicWalletMaxDeposit` | ❌ **حذف** ← جدید v3 | `Package.MagicWalletMaxDeposit` (سقف شارژ) |
| `MagicWalletMaxCredit` | ❌ **حذف** ← جدید v3 | `Package.MagicWalletMaxCredit` (سقف اعتبار) |
| `ShopVAT` | ✅ حفظ | عمومی |
| `CommissionCalculationStrategy` | ✅ حفظ | عمومی (ORM or SP) |
| `AllowDeletingParentsWithChildren` | ✅ حفظ | عمومی |
| `MaxDirectChildrenPerLeg` | ✅ حفظ | عمومی |
| `IsCommissionWithdrawalEnabled` | ✅ حفظ | عمومی |
| `CommissionMinWithdrawalAmount` | ✅ حفظ | عمومی |
| `IsMaintenanceMode` | ✅ حفظ | عمومی |
| `IsAuditLogEnabled` | ✅ حفظ | عمومی |
| `IsVATEnabled` | ✅ حفظ | عمومی |
### ۴.۵ فرمول مالی
```
ActivationFee = Price × 0.45
پکیج نقره‌ای (۵,۶۰۰,۰۰۰ ریال):
├── Balance += ۵,۶۰۰,۰۰۰ (Price)
├── DiscountBalance += ۱۱,۲۰۰,۰۰۰ (Price × DiscountMultiplier)
└── CommissionPool += ۲,۵۲۰,۰۰۰ (ActivationFee)
پکیج پایه (۵۶,۰۰۰,۰۰۰ ریال):
├── Balance += ۵۶,۰۰۰,۰۰۰ (Price)
├── DiscountBalance += ۱۱۲,۰۰۰,۰۰۰ (Price × DiscountMultiplier)
└── CommissionPool += ۲۵,۲۰۰,۰۰۰ (ActivationFee)
```
---
## ۵. فلوی خرید مجدد (Re-Purchase)
### ۵.۱ چرخه حیات کامل
```mermaid
stateDiagram-v2
[*] --> NoPurchase: کاربر ثبت‌نام کرده
NoPurchase --> PackagePurchased: خرید پکیج\n(هر پکیجی)
PackagePurchased --> ClubActivated: فعالسازی باشگاه\n(OTP + قرارداد — فقط بار اول)
ClubActivated --> Shopping: خرج Balance\nدر فروشگاه
Shopping --> MagicMode: Balance == 0
MagicMode --> MagicCharging: شارژ + خرج\n(تا سقف per-package)
MagicCharging --> MagicMode: ادامه
MagicMode --> CycleComplete: Balance == 0\nAND Deposit ≥ MaxDeposit
CycleComplete --> RePurchase: ریست PackagePurchaseMethod\n(membership فعال باقی — Q19)
RePurchase --> ClubActivated: خرید مجدد\n(بدون قرارداد + فیچر DIFF)
```
### ۵.۲ ریست وضعیت بعد تکمیل چرخه (EXIT Magic Mode)
```csharp
// UserOrderService.cs — EXIT Magic Mode — تغییرات لازم:
wallet.WalletMode = WalletMode.Normal;
wallet.MagicCompletedAt = DateTime.UtcNow;
cycle.MagicCompletedAt = DateTime.UtcNow;
// ✅ اضافه شود:
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. **فیچرها بر اساس پکیج جدید — 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;
```
---
## ۶. Commission Pool — تغییرات
### ۶.۱ ساختار جدید
```
هفته ۱:
Pool_نقره‌ای (PackageId=X): TotalAmount = Σ ActivationFee نقره‌ای
Pool_پایه (PackageId=Y): TotalAmount = Σ ActivationFee پایه
هر Pool مستقل:
ValuePerBalance = TotalPoolAmount ÷ TotalBalances
(فقط کاربران همان پکیج)
```
### ۶.۲ ساختار شبکه یکی‌ست
```
[Ali - پایه]
/ \
[Sara - نقره‌ای] [Reza - پایه]
Commission (بالاسری Ali):
Ali → از Pool_پایه (چون Reza پکیج پایه داره)
Ali → از Pool_نقره‌ای (چون Sara پکیج نقره‌ای داره)
→ مجموع هر دو Pool → کیف پول شبکه Ali
→ به تفکیک: X از پایه، Y از نقره‌ای
⚠️ پکیج خود Ali مهم نیست! مهم پکیج زیرمجموعه‌هاست
```
### ۶.۳ فلوی کامل چرخه خرید مجدد و تاثیر بر پورسانت (v5)
```
چرخه ۱ — کاربر "علی" پکیج پایه می‌خرد (۵۶M) — اولین خرید:
────────────────────────────────────────────────────────────────
✅ Balance += 56M
✅ DiscountBalance += 112M (×2)
✅ ActivationFee → Pool_پایه هفته جاری
✅ OTP + امضای قرارداد (فقط این بار — Q19)
✅ فیچرهای پکیج پایه فعال (DIFF = همه اضافه — لیست خالی بود)
✅ ClubMembership:
FirstActivationDate = now, FirstPackageId = پایه
LastActivationDate = now, LastPackageId = پایه
✅ ClubMembershipCycle #1 ساخته می‌شود
✅ NetworkWeeklyBalance (PackageId=پایه, WeekId=هفته جاری)
✅ بالاسری‌ها: تعادل‌ها بر اساس MaxBalancesPerLeg=300 + MaxNetworkLevel=15
✅ پورسانت بالاسری‌ها از Pool_پایه
... خرید از فروشگاه → Balance صفر شد → Magic Mode فعال ...
... خرید جادویی → MagicBalance صفر + Deposit ≥ MaxDeposit → EXIT Magic ...
چرخه ۲ — کاربر "علی" دوباره پکیج پایه می‌خرد:
──────────────────────────────────────────────
✅ ریست وضعیت: PackagePurchaseMethod=None (membership فعال باقی — Q19)
✅ Balance += 56M (مجدد شارژ)
✅ DiscountBalance += 112M (مجدد شارژ)
✅ ActivationFee → Pool_پایه هفته جاری
❌ بدون قرارداد مجدد (Q19 — قبلاً امضا شده)
✅ فیچر DIFF = خالی (همان پکیج → بدون تغییر فیچر — Q20)
✅ ClubMembership:
FirstActivationDate = حفظ, FirstPackageId = حفظ
LastActivationDate = now (بروزرسانی), LastPackageId = پایه
✅ ClubMembershipCycle #2 ساخته می‌شود
✅ بالاسری علی: carryover همه Poolها (بر اساس پکیج زیرمجموعه‌ها) حفظ می‌شود
✅ پورسانت بالاسری: از هر Pool که زیرمجموعه‌ای دارد
... همان چرخه Magic Wallet تکرار ...
چرخه ۵ — کاربر "علی" پکیج نقره‌ای می‌خرد (۵.۶M):
────────────────────────────────────────────────────
✅ Balance += 5.6M
✅ DiscountBalance += 11.2M (×2)
✅ ActivationFee → Pool_نقره‌ای هفته جاری (۲,۵۲۰,۰۰۰)
❌ بدون قرارداد مجدد (Q19)
✅ فیچر DIFF (Q20):
فعلی: [چاتیکا, بیمه, سفر, آموزش] (از پکیج پایه)
جدید: [چاتیکا, بیمه] (از پکیج نقره‌ای)
→ حذف: [سفر, آموزش] → IsActive=false
→ اضافه: [] → هیچی
→ حفظ: [چاتیکا, بیمه]
✅ ClubMembership:
FirstActivationDate = حفظ, FirstPackageId = حفظ (پایه)
LastActivationDate = now (بروزرسانی), LastPackageId = نقره‌ای
✅ ClubMembershipCycle #5 ساخته می‌شود (PackageId=نقره‌ای)
✅ بالاسری‌های علی: carryover همه Poolها حفظ — تغییر پکیج علی تاثیری ندارد!
(carryover بر اساس پکیج زیرمجموعه‌هاست نه خود علی)
✅ پورسانت بالاسری از هر Pool جداگانه:
Pool_پایه: بر اساس زیرمجموعه‌هایی که پکیج پایه دارند
Pool_نقره‌ای: بر اساس زیرمجموعه‌هایی که پکیج نقره‌ای دارند
✅ ActivationFee علی → Pool_نقره‌ای (۲,۵۲۰,۰۰۰)
```
### ۶.۴ تعادل‌ها (NetworkWeeklyBalance) — per-downline-package (v6)
> ⚠️ **تغییر اساسی v6:** تعادل هر کاربر بر اساس پکیج **زیرمجموعه‌ها** گروه‌بندی می‌شود — **نه** پکیج خود کاربر.
> ✅ **قانون carryover v6 (Q23 اصلاح‌شده):** تغییر پکیج خود کاربر **هیچ تاثیری** بر carryover ندارد. carryover مال زیرمجموعه‌هاست.
```
قبل (تک‌پکیج):
NetworkWeeklyBalance: [UserId, WeekId] → یک رکورد
بعد (چند‌پکیج):
NetworkWeeklyBalance: [UserId, WeekId, PackageId] → N رکورد
(N = تعداد پکیج‌های مختلف زیرمجموعه‌ها)
```
**الگوریتم محاسبه تعادل per-downline-package:**
```
برای هر بالاسری (کاربر):
برای هر پکیج فعال (X):
1. واکشی زیرمجموعه‌هایی که پکیج X دارند:
زیرمجموعه‌ها WHERE ClubMembership.LastPackageId = X
2. شمارش: چند نفر تیم چپ + چند نفر تیم راست
3. carryover از هفته قبل:
خواندن رکورد [UserId, PrevWeekId, PackageId=X]
✅ همیشه خوانده می‌شود — مستقل از پکیج خود کاربر!
4. LeftLegTotal = NewLeft + CarryoverLeft
5. RightLegTotal = NewRight + CarryoverRight
6. TotalBalances = MIN(Left, Right) → cap at Package.MaxBalancesPerLeg
7. Remainder → carryover هفته بعد (برای PackageId = X)
8. پورسانت: TotalBalances × ValuePerBalance_X → کیف پول شبکه
مجموع پورسانت از همه Poolها → واریز به کیف پول شبکه کاربر
(به تفکیک مشخص: هر مبلغ از کدام Pool)
```
**مثال جامع — بالاسری "علی":**
```
علی (خودش پکیج پایه داره)
├── تیم چپ:
│ ├── سارا (پکیج پایه)
│ ├── رضا (پکیج پایه)
│ └── مریم (پکیج نقره‌ای)
└── تیم راست:
├── حسین (پکیج پایه)
└── زهرا (پکیج نقره‌ای)
```
```
هفته ۱۰ — محاسبه تعادل علی:
═══════════════════════════════════════════════════════
═══ Pool پکیج پایه (زیرمجموعه‌هایی که پایه دارن) ═══
چپ: سارا + رضا = 2
راست: حسین = 1
carryover هفته ۹: {چپ: 3, راست: 0}
جمع: چپ = 2+3 = 5, راست = 1+0 = 1
تعادل = MIN(5, 1) = 1 (cap 300 → OK)
carryover → هفته ۱۱: {چپ: 4, راست: 0}
پورسانت: 1 × ValuePerBalance_پایه = A ریال
═══ Pool پکیج نقره‌ای (زیرمجموعه‌هایی که نقره‌ای دارن) ═══
چپ: مریم = 1
راست: زهرا = 1
carryover هفته ۹: {چپ: 0, راست: 0}
جمع: چپ = 1+0 = 1, راست = 1+0 = 1
تعادل = MIN(1, 1) = 1 (cap 30 → OK)
carryover → هفته ۱۱: {چپ: 0, راست: 0}
پورسانت: 1 × ValuePerBalance_نقره‌ای = B ریال
═══ مجموع پورسانت علی هفته ۱۰: ═══
کیف پول شبکه += (A + B)
ردیابی: A از Pool پایه، B از Pool نقره‌ای
```
**هفته ۱۱ — علی پکیج خودش رو عوض می‌کنه (پایه → نقره‌ای):**
```
═══ Pool پکیج پایه ═══
carryover از هفته ۱۰: {چپ: 4, راست: 0} ← هنوز هست!
✅ تغییر پکیج خود علی تاثیری نداره!
اعضای جدید: چپ = 0, راست = 1
جمع: چپ = 0+4 = 4, راست = 1+0 = 1
تعادل = MIN(4, 1) = 1
✅ علی هنوز از Pool پایه سود می‌بره (چون زیرمجموعه‌هایی با پکیج پایه داره)
═══ Pool پکیج نقره‌ای ═══
carryover از هفته ۱۰: {چپ: 0, راست: 0}
... محاسبه عادی ...
```
**نکته کلیدی:** پکیج **خود کاربر** فقط تعیین می‌کنه ActivationFee‌اش به کدوم Pool بره.
**تعادل و carryover** بر اساس پکیج **زیرمجموعه‌ها** محاسبه می‌شه.
**Cap (سقف):**
```
اگه تعادل بیشتر از MaxBalancesPerLeg بشه → بریده می‌شه (flush):
تعادل = 500، سقف = 300 → CappedBalance = 300، Flushed = 200
⚠️ Flushed از بین می‌ره — carry نمی‌شه!
```
### ۶.۵ تغییرات SP (v3 — بروزرسانی)
| SP | تغییرات v2 | تغییرات اضافی v3 |
|----|------------|------------------|
| `sp_CalculateWeeklyBalances` | پارامتر `@PackageId` | `@MaxBalancesPerLeg` و `@MaxNetworkLevel` هم **پارامتر** شوند (نه hardcoded) |
| `sp_CalculateWeeklyCommissionPool` | پارامتر `@PackageId` | Pool فقط از تعادل‌های همان PackageId |
**sp_CalculateWeeklyBalances — تغییرات ساختاری:**
```sql
-- قبل (v2 — فقط PackageId فیلتر):
CREATE PROCEDURE sp_CalculateWeeklyBalances
@WeekDefinitionId BIGINT,
@PackageId BIGINT,
@MaxBalancesPerLeg INT = 300, -- ← hardcoded!
@MaxNetworkLevel INT = 15 -- ← hardcoded!
-- بعد (v3 — همه داینامیک):
CREATE PROCEDURE sp_CalculateWeeklyBalances
@WeekDefinitionId BIGINT,
@PackageId BIGINT,
@MaxBalancesPerLeg INT, -- ← از Package entity خوانده می‌شود
@MaxNetworkLevel INT, -- ← از Package entity خوانده می‌شود
@ForceRecalculate BIT = 0,
@RowCount INT OUTPUT
```
**StoredProcedureCommissionCalculationStrategy — تغییرات:**
```csharp
// قبل: فقط WeekDefinitionId پاس می‌داد
await connection.ExecuteAsync("CMS.sp_CalculateWeeklyBalances",
new { WeekDefinitionId = weekId, ForceRecalculate = true });
// بعد (v3): پکیج + تنظیمات داینامیک
await connection.ExecuteAsync("CMS.sp_CalculateWeeklyBalances",
new {
WeekDefinitionId = weekId,
PackageId = package.Id,
MaxBalancesPerLeg = package.MaxBalancesPerLeg,
MaxNetworkLevel = package.MaxNetworkLevel,
ForceRecalculate = true
});
```
### ۶.۶ تغییرات Service (v3)
```csharp
// WeeklyCommissionCalculationService — Loop روی پکیج‌ها:
var activePackages = await _context.Packages
.Where(p => p.IsActive && !p.IsDeleted)
.ToListAsync();
foreach (var package in activePackages)
{
_logger.LogInformation(
"Calculating commission for package {Id}: {Title} " +
"(MaxBalances={Max}, MaxLevel={Level})",
package.Id, package.Title,
package.MaxBalancesPerLeg, package.MaxNetworkLevel);
// محاسبه تعادل‌ها — هر پکیج با تنظیمات خودش
await strategy.CalculateWeeklyBalancesAsync(
weekId, package.Id,
package.MaxBalancesPerLeg, package.MaxNetworkLevel);
// محاسبه Pool — هر پکیج جداگانه
await strategy.CalculateWeeklyPoolAsync(weekId, package.Id);
}
```
### ۶.۷ گزارش‌دهی پورسانت per-package (v3 — جدید)
> ⚠️ **فعلاً `package_id` در هیچ‌کدام از پیام‌های commission.proto وجود ندارد!**
#### تغییرات Proto (commission.proto):
```diff
message UserWeeklyBalanceModel {
// ... فیلدهای فعلی ...
+ int64 package_id = 17;
+ string package_title = 18;
}
message UserCommissionPayoutModel {
// ... فیلدهای فعلی ...
+ int64 package_id = 16;
+ string package_title = 17;
}
message CustomerCommissionPayoutModel {
// ... فیلدهای فعلی ...
+ int64 package_id = 10;
+ string package_title = 11;
}
message CustomerWeeklyBalanceModel {
// ... فیلدهای فعلی ...
+ int64 package_id = 15;
+ string package_title = 16;
}
+ // مدل خلاصه پورسانت per-package برای مشتری
+ message CustomerCommissionPackageSummary {
+ int64 package_id = 1;
+ string package_title = 2;
+ int32 downline_count = 3; // تعداد زیرمجموعه با این پکیج
+ int32 left_leg_members = 4;
+ int32 right_leg_members = 5;
+ int32 total_balances = 6;
+ int64 commission_earned = 7; // پورسانت کسب‌شده از این پکیج
+ string commission_formatted = 8;
+ }
// Request فیلتر بر اساس پکیج
message GetAllWeeklyBalancesByFilterRequest {
// ... فیلدهای فعلی ...
+ Int64Value package_id = 7; // فیلتر اختیاری
}
message GetUserCommissionPayoutsRequest {
// ... فیلدهای فعلی ...
+ Int64Value package_id = 7; // فیلتر اختیاری
}
```
#### FrontOffice — گزارش پورسانت per-package (صفحه جدید/بهبود):
```
┌─── پاداش‌های من — هفته ۱۰ ────────────────────────────────────────────┐
│ │
│ 📊 خلاصه بر اساس پکیج: │
│ │
│ ┌── پکیج پایه ─────────────────┐ ┌── پکیج نقره‌ای ───────────────┐ │
│ │ زیرمجموعه: ۱۲ نفر │ │ زیرمجموعه: ۵ نفر │ │
│ │ تیم اول: ۷ │ تیم دوم: ۵ │ │ تیم اول: ۳ │ تیم دوم: ۲ │ │
│ │ تعادل: ۵ │ │ تعادل: ۲ │ │
│ │ 💰 پاداش: ۱,۲۵۰,۰۰۰ تومان │ │ 💰 پاداش: ۱۸۰,۰۰۰ تومان │ │
│ └───────────────────────────────┘ └──────────────────────────────┘ │
│ │
│ 📦 مجموع پاداش هفته: ۱,۴۳۰,۰۰۰ تومان │
│ ├── از پکیج پایه: ۱,۲۵۰,۰۰۰ │
│ └── از پکیج نقره‌ای: ۱۸۰,۰۰۰ │
└─────────────────────────────────────────────────────────────────────────┘
```
#### BackOffice — گزارش ادمین با فیلتر پکیج:
```
┌─── گزارش تعادل‌ها — هفته ۱۰ ───────────────────────────────────────┐
│ │
│ فیلتر: [▼ پکیج: همه ▼] [کاربر: ___] [هفته: ▼ هفته ۱۰ ▼] [جستجو]│
│ ├── همه │
│ ├── پکیج پایه │
│ └── پکیج نقره‌ای │
│ │
│ # │ کاربر │ پکیج │ تیم اول │ تیم دوم │ تعادل │ سهم استخر │
│ ──┼──────────┼─────────┼─────────┼─────────┼───────┼────────────────│
│ 1 │ علی │ پایه │ ۴۵ │ ۵۲ │ ۴۵ │ ۲,۲۵۰,۰۰۰ │
│ 2 │ سارا │ نقره‌ای │ ۱۲ │ ۸ │ ۸ │ ۱۴۴,۰۰۰ │
│ 3 │ رضا │ پایه │ ۳۰ │ ۲۵ │ ۲۵ │ ۱,۲۵۰,۰۰۰ │
│ 4 │ رضا │ نقره‌ای │ ۲ │ ۰ │ ۰ │ ۰ │
│ │
│ خلاصه: پکیج پایه: ۷۰ تعادل | پکیج نقره‌ای: ۸ تعادل │
└──────────────────────────────────────────────────────────────────────┘
```
---
## ۷. Event-Driven Side Effects
### ۷.۱ ساخت پکیج جدید
```mermaid
flowchart LR
A["ساخت پکیج جدید<br/>(از BackOffice)"] --> B["PackageCreatedEvent"]
B --> C["ایجاد Pool خالی<br/>برای هفته جاری"]
B --> D["لاگ ادمین"]
```
### ۷.۲ جدول رویدادها
| رویداد | Side Effect |
|--------|------------|
| `PackageCreated` | ساخت WeeklyCommissionPool خالی هفته جاری |
| `PackageDeactivated` | هشدار ادمین — Pool موجود تکمیل شود |
| `PackagePurchased` | ActivationFee → Pool پکیج + شارژ wallets |
| `MagicCycleCompleted` | ریست PackagePurchaseMethod + حفظ membership فعال (Q19) |
---
## ۷٫۵ تحلیل جامع Side Effectها (v4 — کشف جدید)
> ⚠️ **این بخش ساید‌افکت‌هایی را مستند می‌کند که در تحلیل‌های v1–v3 کشف نشده بودند.**
> هر آیتم با بررسی عمیق کدبیس CMS، FrontOffice و BackOffice شناسایی شده.
### ۷٫۵٫۱ Entity — WalletChangeLog بدون PackageId
> 🔴 **بحرانی — ۲۱ محل ساخت WalletChangeLog**
Entity فعلی `UserWalletChangeLog` **هیچ فیلد PackageId ندارد**. در سیستم چندپکیجی نمی‌توان ردیابی کرد کدام پکیج باعث تغییر کیف‌پول شده.
**محل‌های ساخت WalletChangeLog (۲۱ مورد):**
| # | فایل | کانتکست |
|---|------|---------|
| 1-2 | `UserOrderService.cs` | خرید فروشگاه + لغو مشتری |
| 3-4 | `VerifyPackagePurchaseCommandHandler` | Balance + DiscountBalance |
| 5 | `VerifyBasePackagePaymentCommandHandler` | Balance |
| 6-7 | `VerifyGoldenPackagePurchaseCommandHandler` | Balance + Discount |
| 8 | `VerifyMagicWalletChargeCommandHandler` | شارژ جادویی |
| 9-10 | `CheckAndProcessDayaLoansCommandHandler` | Balance + Discount |
| 11-12 | `CreateManualPaymentCommandHandler` | Balance + Discount |
| 13-18 | `ChargeUserWalletsCommandHandler` | ۶ شاخه switch |
| 19 | `CancelOrderByAdminCommandHandler` | ریفاند ادمین |
| 20 | `SpCommissionCalculationStrategy` | پرداخت پورسانت |
| 21 | `OrmCommissionCalculationStrategy` | پرداخت پورسانت |
**اقدام لازم:**
```csharp
// اضافه به UserWalletChangeLog entity:
public long? PackageId { get; set; }
public virtual Package Package { get; set; }
```
+ Migration + بروزرسانی ۲۱ محل
---
### ۷٫۵٫۲ Validator — سقف hardcoded 1B
> 🟡 **متوسط — ۳ Validator**
| Validator | فایل | قانون | مشکل |
|-----------|------|-------|------|
| `ChargeMagicWalletCommandValidator` | FluentValidation | `.LessThanOrEqualTo(1_000_000_000)` | سقف global — پکیج نقره‌ای ممکنه ۱۰۰M باشه |
| `ChargeDiscountWalletCommandValidator` | FluentValidation | `.LessThanOrEqualTo(1_000_000_000)` | سقف global |
| `CreateManualPaymentCommandValidator` | FluentValidation | `.LessThanOrEqualTo(1_000_000_000)` | سقف global |
**اقدام:** Validator باید PackageId بگیره و از `Package.MagicWalletMaxDeposit` بخونه، یا حداکثر بین همه پکیج‌ها.
---
### ۷٫۵٫۳ Magic Wallet — ۶ Side Effect
> 🔴 **بحرانی — چرخه حیات Magic Wallet کامل global است**
| # | فایل | مشکل | شدت |
|---|------|------|------|
| **MW1** | `ChargeMagicWalletCommandHandler` | `remainingDeposit = SystemConstants.MagicWalletMaxDeposit - wallet.MagicTotalDeposited` ← global 1B | 🔴 |
| **MW2** | `VerifyMagicWalletChargeCommandHandler` | `creditAmount = depositAmount * SystemConstants.MagicWalletMultiplier` ← global ×2.5 | 🔴 |
| **MW3** | `UserOrderService.cs` L347 | EXIT trigger: `wallet.MagicTotalDeposited >= SystemConstants.MagicWalletMaxDeposit` ← global 1B | 🔴 |
| **MW4** | `UserOrderService.cs` L363 | ENTRY trigger: فقط `PackagePurchaseMethod != None` — چک نمی‌کنه **کدام** پکیج | 🟡 |
| **MW5** | `WalletGrpcService` | `GetMagicWalletStatus` → سقف و باقیمانده global به FrontOffice ارسال | 🟡 |
| **MW6** | `ActivateClubMembershipCommandHandler` | Guard: `WalletMode == Magic → throw` — مدت Magic وابسته به سقف per-package | 🟡 |
**فلوی مشکل‌دار:**
```
پکیج نقره‌ای (MaxDeposit=100M):
کاربر ۱۰۰M شارژ کرد → باید EXIT شه
❌ EXIT نمی‌شه! چون سیستم ۱B (global) چک می‌کنه
❌ کاربر تا ابد در Magic Mode گیر می‌افته!
پکیج طلایی (MaxDeposit=2B):
کاربر ۱B شارژ کرد → EXIT اشتباه!
❌ سیستم فکر می‌کنه سقف رسیده
❌ کاربر زودتر از موعد از Magic خارج می‌شه!
```
---
### ۷٫۵٫۴ JWT Claims — بدون PackageId
> 🟡 **بالا — اطلاعات ناکافی JWT**
| Claim فعلی | نوع | مشکل |
|------------|-----|------|
| `HasPurchasedGoldenPackage` | `bool` | فقط boolean — نمی‌گه **کدام** پکیج |
| `PackagePurchaseMethod` | missing | در JWT نیست — فقط در DB |
| `CanRepurchase` | **وجود ندارد** | هیچ‌جا تعریف نشده — UI نمی‌تونه دکمه خرید مجدد نشون بده |
| `PackageId` | **وجود ندارد** | فرانت نمی‌دونه کاربر کدام پکیج رو داره |
| `PackageTitle` | **وجود ندارد** | نام پکیج در JWT نیست |
**اقدام — Claims جدید:**
```csharp
claims.Add("PackageId", membership.PackageId?.ToString() ?? "");
claims.Add("PackageTitle", package?.Title ?? "");
claims.Add("CanRepurchase", HasCompletedMagicCycle(membership).ToString());
// حذف HasPurchasedGoldenPackage → جایگزین با PackageId > 0
```
---
### ۷٫۵٫۵ Club Features — تخصیص global → DIFF (v5)
> 🔴 **بحرانی — فیچرها باید DIFF/تفاضل باشند (Q20)**
```csharp
// فعلی (اشتباه) — ActivateClubMembershipCommandHandler.cs + AcceptClubMembershipContractCommandHandler.cs:
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
// ↑ همیشه همه فیچرها (Chatika, Bime, Trip, Learn) — صرف‌نظر از پکیج!
```
**مشکل ۱:** پکیج نقره‌ای ممکنه فقط ۲ فیچر داشته باشه ولی سیستم فعلی **همه ۴ فیچر** رو فعال می‌کنه.
**مشکل ۲:** در خرید مجدد، باید فقط **تفاضل** اعمال بشه (Q20) — نه تخصیص مجدد همه.
**اقدام (v5):**
```csharp
// خرید اول (currentFeatures خالی → همه فیچرها اضافه):
await _featureDiffService.ApplyFeatureDiffAsync(
userId, membershipId,
oldPackageId: 0, // بدون پکیج قبلی
newPackageId: package.Id);
// خرید مجدد (مقایسه + تفاضل):
await _featureDiffService.ApplyFeatureDiffAsync(
userId, membershipId,
oldPackageId: membership.LastPackageId,
newPackageId: newPackage.Id);
```
---
### ۷٫۵٫۶ Notification — بدون PackageId
> 🟡 **متوسط — ۴ Notification**
| # | Notification | مشکل |
|---|-------------|------|
| N1 | `CommissionDepositedNotification` | پیام: "پورسانت X ریال واریز شد" — نمی‌گه از **کدام پکیج** |
| N2 | `ClubActivatedNotification` | پیام: "عضویت فعال شد" — نمی‌گه **کدام پکیج** فعال شد |
| N3 | `CommissionPayoutFailedNotification` | بدون PackageId |
| N4 | Daya SMS | "وام دایا تایید شد" — نمی‌گه برای کدام پکیج |
**اقدام:** Interface `INotification` باید `PackageId` + `PackageTitle` بگیره.
---
### ۷٫۵٫۷ Background Services — ۳ سرویس
> 🟡 **متوسط**
| # | سرویس | مشکل |
|---|--------|------|
| BG1 | `ClubMembershipCycleSeedService` | چرخه‌ها را با `PurchaseAmount=0` seed می‌کنه — PackageId ندارد |
| BG2 | `DayaLoanStatusCheckWorker` | `DayaLoanAmount` global — بدون PackageId |
| BG3 | `ChatikaAccountActivationWorker` | همه اعضا — بدون فیلتر پکیج (Chatika ممکنه فقط پکیج پایه) |
---
### ۷٫۵٫۸ Commission Reports — بدون PackageId
> 🟡 **متوسط**
| # | فایل | مشکل |
|---|------|------|
| CR1 | `CommissionWithdrawalReportService` | گزارش برداشت global — بدون فیلتر پکیج |
| CR2 | `WorkerExecutionLog` | لاگ اجرای Hangfire — نمی‌گه کدام پکیج محاسبه شد |
| CR3 | Admin manual trigger endpoint | `EnqueueCommissionCalculation` بدون پارامتر PackageId |
---
### ۷٫۵٫۹ FrontOffice — متن‌های hardcoded
> 🔴 **بحرانی — مسئولیت حقوقی قرارداد**
| # | فایل | مشکل | شدت |
|---|------|------|------|
| FO1 | `ClubMembershipContractDialog.razor` | **"۵۶ میلیون تومان (پکیج پایه)"** در متن قرارداد حقوقی — اگه نقره‌ای بخره اشتباهه! | 🔴 حقوقی |
| FO2 | `PackagePurchaseBottomSheet.razor` | **"خرید پکیج پایه ۵۶ میلیون تومان"** hardcoded | 🔴 |
| FO3 | `ActivationSection.razor` | `56_000_000` hardcoded در محاسبه هزینه | 🔴 |
| FO4 | `MagicWalletChargePage.razor` | "×۲.۵" و "سقف واریز: ۱۰۰ میلیون" hardcoded در ۶ جا | 🟡 |
| FO5 | `MagicWalletChargePage.razor` L112 | `_chargeAmount * 2.5m` hardcoded در **C# code** | 🔴 |
| FO6 | صفحات متعدد | **"پکیج طلایی"** hardcoded (حداقل ۵ جا) — نام اشتباه! | 🟡 |
| FO7 | Activation flow | `PackageId = 1` hardcoded | 🔴 |
| FO8 | `HasPurchasedPackage` claim | `bool` — UI نمی‌تونه multi-package نشون بده | 🟡 |
| FO9 | Network tree | بدون نمایش پکیج هر نود | 🟢 |
---
### ۷٫۵٫۱۰ BackOffice — ۸ Side Effect
> 🟡 **بالا**
| # | فایل | مشکل | شدت |
|---|------|------|------|
| BO-S1 | `ManualActivationDialog.razor` | مبلغ `56_000_000` hardcoded + `Disabled` + **بدون package selector** | 🔴 |
| BO-S2 | `SystemConfigurationPage.razor` | تنظیمات global — MaxWithdrawal, MaxTreeDepth, CommissionPercent باید per-package بشن | 🔴 |
| BO-S3 | Commission Dashboard | یک Pool واحد نشون می‌ده — بدون per-package | 🟡 |
| BO-S4 | User detail page | `HasPurchasedGoldenPackage` boolean — نمی‌گه کدام پکیج | 🟡 |
| BO-S5 | Package CRUD grid | فقط Title + Price — فیلدهای v3 ندارد | 🟡 |
| BO-S6 | Club members grid | ستون PackageTitle ✅ اما فیلتر ❌ | 🟢 |
| BO-S7 | CSV exports (۳ جا) | بدون ستون پکیج | 🟡 |
| BO-S8 | Network tree viewer | بدون پکیج هر نود + `MaxNetworkDepth` global | 🟡 |
---
### ۷٫۵٫۱۱ خلاصه آماری Side Effectها
```
═══════════════════════════════════════════════════════════════
لایه 🔴 بحرانی 🟡 بالا/متوسط 🟢 کم جمع
═══════════════════════════════════════════════════════════════
CMS Domain ۲ ۱ ۰ ۳
CMS Application ۶ ۴ ۰ ۱۰
CMS Infrastructure ۱ ۳ ۰ ۴
CMS Validators ۰ ۳ ۰ ۳
FrontOffice ۵ ۳ ۱ ۹
BackOffice ۲ ۵ ۱ ۸
Notifications ۰ ۴ ۰ ۴
Background Jobs ۰ ۳ ۰ ۳
─────────────────────────────────────────────────────────────
جمع کل ۱۶ ۲۶ ۲ ۴۴
═══════════════════════════════════════════════════════════════
```
> **مجموع ساید‌افکت‌های کشف‌شده v4: ۴۴ مورد** (نسبت به ۴۸ تغییر اصلی v3)
> **بیشتری تغییرات لازم از ساید‌افکت‌ها ناشی می‌شوند!**
---
## ۸. Seed Data
```sql
-- پکیج پایه (۵۶ میلیون تومان)
INSERT INTO "CMS"."Packages" (
"Title", "Description", "Price", "IsActive", "IsBasePackage",
"SupportsDayaPurchase", "SupportsDirectPurchase",
"ActivationFee", "DiscountMultiplier", "MagicWalletMultiplier",
"MaxBalancesPerLeg", "MaxNetworkLevel",
"MagicWalletMaxDeposit", "MagicWalletMaxCredit",
"SortOrder", "ImagePath"
) VALUES (
'پکیج پایه', 'پکیج اصلی باشگاه مشتریان کارا بازار سلامت',
56000000, true, true,
true, true,
25200000, 2.0, 2.5,
300, 15,
1000000000, 2500000000,
2, ''
);
-- پکیج نقره‌ای (۵.۶ میلیون تومان)
INSERT INTO "CMS"."Packages" (
"Title", "Description", "Price", "IsActive", "IsBasePackage",
"SupportsDayaPurchase", "SupportsDirectPurchase",
"ActivationFee", "DiscountMultiplier", "MagicWalletMultiplier",
"MaxBalancesPerLeg", "MaxNetworkLevel",
"MagicWalletMaxDeposit", "MagicWalletMaxCredit",
"SortOrder", "ImagePath"
) VALUES (
'پکیج نقره‌ای', 'پکیج سطح نقره‌ای باشگاه مشتریان',
5600000, true, false,
false, true,
2520000, 2.0, 2.5,
30, 15,
100000000, 250000000,
1, ''
);
-- فیچرهای پکیج پایه: همه فیچرها
INSERT INTO "CMS"."PackageFeatures" ("PackageId", "ClubFeatureId", "IsIncluded")
SELECT base."Id", cf."Id", true
FROM "CMS"."Packages" base
CROSS JOIN "CMS"."ClubFeatures" cf
WHERE base."IsBasePackage" = true AND cf."IsDeleted" = false;
-- فیچرهای پکیج نقره‌ای: تعیین می‌شود از BackOffice
```
---
## ۹. Migration داده‌های فعلی
```sql
-- ========================================
-- STEP 1: مشخص کردن ID پکیج پایه
-- ========================================
DO $$
DECLARE base_pkg_id BIGINT;
BEGIN
SELECT "Id" INTO base_pkg_id
FROM "CMS"."Packages" WHERE "IsBasePackage" = true LIMIT 1;
-- STEP 2: ClubMembership — v5 migration (Q21)
-- ActivatedAt → FirstActivationDate + LastActivationDate
UPDATE "CMS"."ClubMemberships"
SET "FirstActivationDate" = "ActivatedAt",
"LastActivationDate" = "ActivatedAt",
"FirstPackageId" = base_pkg_id,
"LastPackageId" = base_pkg_id
WHERE "FirstActivationDate" IS NULL;
-- STEP 3: ClubMembershipCycle
UPDATE "CMS"."ClubMembershipCycles"
SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL;
-- STEP 4: WeeklyCommissionPool
UPDATE "CMS"."WeeklyCommissionPools"
SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL;
-- STEP 5: UserCommissionPayout
UPDATE "CMS"."UserCommissionPayouts"
SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL;
-- STEP 6: NetworkWeeklyBalance (v3 — جدید)
UPDATE "CMS"."NetworkWeeklyBalances"
SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL;
RAISE NOTICE 'Migration completed for PackageId=%', base_pkg_id;
END $$;
-- STEP 7: Verify — همه باید 0 باشند
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
SELECT 'Pools', COUNT(*) FROM "CMS"."WeeklyCommissionPools" WHERE "PackageId" IS NULL
UNION ALL
SELECT 'Payouts', COUNT(*) FROM "CMS"."UserCommissionPayouts" WHERE "PackageId" IS NULL
UNION ALL
SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" IS NULL;
```
---
## ۱۱. Impact Analysis — ۹۵+ تغییر در ۶ لایه (v5)
> ۵۱ تغییر اصلی (بخش ۱۰) + ۴۴ سایدافکت (بخش ۷.۵) = **۹۵ تغییر کل**
### ۱۰.۱ لایه Domain (۱۰ تغییر)
| # | فایل | نوع | شدت | v3? |
|---|------|-----|------|-----|
| D1 | `Package.cs` | اضافه **۱۱ فیلد** جدید (v2: ۷ + v3: ۴) | 🟡 | 🔄 |
| D2 | `PackageFeature.cs` | Entity جدید + EF Config | 🔴 | |
| 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) | 🟡 | 🔄 |
| D7 | `SystemConstants.cs` | حذف **۷ ثابت** (v2: ۵ + v3: ۲)، حفظ ۱۱ | 🟡 | 🔄 |
| D8 | EF Migration + Seed | schema + data migration | 🔴 | |
| **D9** | **`NetworkWeeklyBalance.cs`** | **اضافه `PackageId` + Unique(UserId,WeekId,PackageId)** | **🔴** | **🆕** |
| **D10** | **Data volume impact** | **رکوردهای تعادل ×N (تعداد پکیج)** | **🟡** | **🆕** |
| **D11** | **`UserWalletChangeLog.cs`** | **اضافه `PackageId` — ۲۱ محل ساخت باید بروزرسانی شوند** | **🔴** | **🆕 v4** |
### ۱۰.۲ لایه Application (۱۲ تغییر)
| # | فایل | نوع | شدت |
|---|------|-----|------|
| A1 | `ActivateClubMembershipCommandHandler` | فیچر از PackageFeature + ActivationFee + re-activate | 🔴 |
| A2 | `VerifyPackagePurchaseCommandHandler` | DiscountMultiplier + UserPackagePurchase + generic | 🔴 |
| A3 | `VerifyBasePackagePaymentCommandHandler` | DiscountMultiplier + UserPackagePurchase + generic | 🔴 |
| A4 | `VerifyGoldenPackagePurchaseCommandHandler` | فیکس DiscountBalance + UserPackagePurchase + generic | 🔴 |
| A5 | `InitiateBasePackagePaymentCommandHandler` | حذف ID=4 + generic | 🟡 |
| A6 | `PurchaseGoldenPackageCommandHandler` | حذف فیلتر "طلایی" + generic | 🟡 |
| A7 | `PurchasePackageCommandHandler` | اجازه re-purchase | 🟡 |
| A8 | `CreateManualPaymentCommandHandler` | DiscountMultiplier از Package | 🟡 |
| A9 | `CheckAndProcessDayaLoansCommandHandler` | حذف ID=4 + DiscountMultiplier | 🟡 |
| 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)** | **فیچر 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 (۶ تغییر)
| # | فایل | نوع | شدت | v3? |
|---|------|-----|------|-----|
| I1 | `sp_CalculateWeeklyBalances` | پارامتر `@PackageId` + **`@MaxBalancesPerLeg`** + **`@MaxNetworkLevel`** (حذف hardcode) | 🔴 | 🔄 |
| I2 | `sp_CalculateWeeklyCommissionPool` | پارامتر `@PackageId` + فیلتر تعادل‌های همان پکیج | 🔴 | 🔄 |
| I3 | `WeeklyCommissionCalculationService` | Loop روی پکیج‌ها + ارسال تنظیمات هر پکیج | 🟡 | 🔄 |
| I4 | `OrmCommissionCalculationStrategy` | فیلتر PackageId + خواندن MaxBalancesPerLeg/MaxNetworkLevel از Package | 🔴 | 🔄 |
| I5 | `SpCommissionCalculationStrategy` | پاس دادن PackageId + MaxBalancesPerLeg + MaxNetworkLevel | 🟡 | 🔄 |
| **I6** | **Carryover logic** | **Week-shifting per-package: carryover فقط رکوردهای همان PackageId** | **🔴** | **🆕** |
### ۱۰.۴ لایه Proto/gRPC (۶ تغییر)
| # | فایل | نوع | شدت | v3? |
|---|------|-----|------|-----|
| P1 | `package.proto` | فیلدهای جدید Package (۱۱ فیلد) | 🟡 | 🔄 |
| P2 | `clubmembership.proto` | `package_id` در request/response | 🟡 | |
| P3 | `commission.proto` | **`package_id` + `package_title`** در ۴ message: UserWeeklyBalance, UserCommissionPayout, CustomerCommissionPayout, CustomerWeeklyBalance | **🔴** | **🔄** |
| P4 | `PackageGrpcService.cs` | Generic purchase + CRUD | 🟡 | |
| **P5** | **`commission.proto`** | **Message جدید: `CustomerCommissionPackageSummary`** (خلاصه per-package) | **🟡** | **🆕** |
| **P6** | **`commission.proto`** | **فیلتر اختیاری `package_id` در Request‌های** GetWeeklyBalances + GetPayouts | **🟡** | **🆕** |
### ۱۰.۵ لایه FrontOffice (۸ تغییر)
| # | فایل | نوع | شدت | v3? |
|---|------|-----|------|-----|
| F1 | `Packages.razor` | کاشی‌های پکیج از API | 🔴 | |
| F2 | `PackageDetail.razor` | فیچرها از PackageFeature | 🟡 | |
| F3 | `ActivationSection.razor` | حذف hardcoded 56M | 🟡 | |
| F4 | `ClubMembershipContractDialog.razor` | متن قرارداد داینامیک | 🟡 | |
| F5 | `MyPackages.razor` | نمایش نوع پکیج + re-purchase | 🟡 | |
| F6 | `PackageService.cs` | فیکس stub GetPurchaseHistory | 🟡 | |
| **F7** | **`CommissionDashboardPage.razor`** | **خلاصه پاداش per-package (کارت‌های جداگانه هر پکیج)** | **🔴** | **🆕** |
| **F8** | **`WeeklyBalancePage.razor`** | **تعادل‌ها per-package (تیم اول/دوم بر اساس پکیج)** | **🟡** | **🆕** |
| **F9** | **`ClubMembershipContractDialog.razor`** | **متن حقوقی "۵۶ میلیون تومان" — مسئولیت حقوقی!** | **🔴** | **🆕 v4** |
| **F10** | **`MagicWalletChargePage.razor`** | **×۲.۵ و سقف واریز hardcoded در ۶ جا (متن + کد C#)** | **🔴** | **🆕 v4** |
| **F11** | **صفحات متعدد** | **"پکیج طلایی" hardcoded (۵+ جا) — نام اشتباه** | **🟡** | **🆕 v4** |
| **F12** | **Activation flow** | **`PackageId = 1` hardcoded** | **🔴** | **🆕 v4** |
### ۱۰.۶ لایه BackOffice (۸ تغییر)
| # | فایل | نوع | شدت | v3? |
|---|------|-----|------|-----|
| BO1 | `PackageCreateDialog.razor` | فیلدهای جدید (**۱۱ فیلد** شامل MaxBalancesPerLeg, MaxNetworkLevel, MagicWallet limits) | 🟡 | 🔄 |
| BO2 | `PackageEditDialog.razor` | فیلدهای جدید + **Quick Access فیچرها** (checkbox فیچرها در همان فرم) | 🟡 | 🔄 |
| BO3 | `PackageFeatureMatrixPage`**جدید** | ماتریس پکیج×فیچر | 🔴 | |
| BO4 | `ActivateClubDialog.razor` | dropdown انتخاب پکیج | 🟡 | |
| **BO5** | **`Dashboard.razor` (Commission)** | **فیلتر dropdown پکیج + خلاصه per-package** | **🟡** | **🆕** |
| **BO6** | **`WeeklyReportsPage.razor`** | **فیلتر پکیج + CSV export per-package** | **🟡** | **🆕** |
| **BO7** | **`BalancesReportPage.razor`** | **فیلتر پکیج + ستون پکیج در جدول تعادل‌ها** | **🟡** | **🆕** |
| **BO8** | **`UserPayoutsPage.razor`** | **ستون پکیج در لیست پرداخت‌ها + فیلتر** | **🟡** | **🆕** |
| **BO9** | **`ManualActivationDialog.razor`** | **مبلغ ۵۶M hardcoded + Disabled + بدون package selector** | **🔴** | **🆕 v4** |
| **BO10** | **`SystemConfigurationPage.razor`** | **تنظیمات global — باید per-package بشن** | **🔴** | **🆕 v4** |
| **BO11** | **CSV exports (۳ جا)** | **بدون ستون پکیج** | **🟡** | **🆕 v4** |
---
## ۱۱. فازبندی پیاده‌سازی
### فاز ۰ — فیکس باگ‌های فوری ≈ ۱ روز
| تسک | شرح |
|-----|------|
| **T0.1** | فیکس `VerifyGoldenPackagePurchase` — اضافه DiscountBalance (`Amount × 2`) |
| **T0.2** | فیکس `VerifyGoldenPackagePurchase` — ساخت `UserPackagePurchase` |
| **T0.3** | فیکس `VerifyPackagePurchase` — ساخت `UserPackagePurchase` |
| **T0.4** | فیکس `VerifyBasePackagePayment` — ساخت `UserPackagePurchase` |
### فاز ۱ — زیرساخت (Domain + DB) ≈ ۳ روز
| تسک | شرح | v3? |
|-----|----- |-----|
| **T1.1** | بروزرسانی `Package` entity (**۱۱ فیلد** جدید: v2 ۷ + v3 ۴ شامل MaxBalancesPerLeg, MaxNetworkLevel, MagicWalletMaxDeposit, MagicWalletMaxCredit) | 🔄 |
| **T1.2** | ایجاد `PackageFeature` entity + EF Config | |
| **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) | 🔄 |
| **T1.7** | حذف **۷ ثابت** از `SystemConstants` (v2: ۵ + v3: MaxWeeklyBalancesPerLeg, MaxNetworkLevel) | 🔄 |
| **T1.8** | Database Migration + Seed Data (۲ پکیج + فیچرها) | |
| **T1.9** | Data Migration: کاربران فعلی → PackageId = پکیج پایه | |
| **T1.10** | بروزرسانی Proto‌ها (package + clubmembership + commission) | 🔄 |
| **T1.11** | **اضافه `PackageId` به `NetworkWeeklyBalance` + Unique(UserId,WeekId,PackageId)** | **🆕** |
| **T1.12** | **Data Migration: `NetworkWeeklyBalance` موجود → PackageId = پکیج پایه** | **🆕** |
| **T1.13** | **اضافه `PackageId` به `UserWalletChangeLog` + Migration داده** | **🆕 v4** |
### فاز ۲ — منطق کسب‌وکار ≈ ۵ روز (v4: +۱)
| تسک | شرح | v4? |
|-----|----- |-----|
| **T2.1** | ادغام Verify handlers → Generic (DiscountMultiplier + UserPackagePurchase) | |
| **T2.2** | ادغام Purchase handlers → Generic (حذف "طلایی"، حذف ID=4) | |
| **T2.3** | بروزرسانی `ActivateClubMembership` — فیچر DIFF از PackageFeature (Q20) + First/LastActivation (Q21) | 🔄 v5 |
| **T2.4** | بروزرسانی `ActivateClubMembership` — ActivationFee از Package | |
| **T2.5** | اجازه re-purchase در Guards (G1G3) | |
| **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 خالی | |
| **T2.11** | **Magic Wallet: `ChargeMagicWallet` + `VerifyMagicWalletCharge` + `UserOrderService` EXIT/ENTRY → خواندن سقف/ضریب از Package** | **🆕 v4** |
| **T2.12** | **Validators (۳ فایل): حذف hardcoded 1B → داینامیک** | **🆕 v4** |
| **T2.13** | **JWT Claims: اضافه PackageId + PackageTitle + CanRepurchase، حذف HasPurchasedGoldenPackage** | **🆕 v4** |
| **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** |
### فاز ۳ — محاسبه پورسانت ≈ ۴ روز (موازی با فاز ۲)
| تسک | شرح | v3? |
|-----|----- |-----|
| **T3.1** | بروزرسانی `sp_CalculateWeeklyBalances``@PackageId` + **`@MaxBalancesPerLeg`** + **`@MaxNetworkLevel`** (حذف hardcode ۳۰۰/۱۵) | 🔄 |
| **T3.2** | بروزرسانی `sp_CalculateWeeklyCommissionPool``@PackageId` + فیلتر تعادل‌های همان پکیج | 🔄 |
| **T3.3** | بروزرسانی `WeeklyCommissionCalculationService` — Loop روی پکیج‌ها + ارسال تنظیمات | 🔄 |
| **T3.4** | بروزرسانی `OrmCommissionCalculationStrategy` — فیلتر PackageId + خواندن Max از Package | 🔄 |
| **T3.5** | **بروزرسانی `SpCommissionCalculationStrategy` — پاس دادن PackageId + MaxBalancesPerLeg + MaxNetworkLevel** | **🆕** |
| **T3.6** | **Carryover per-package: week-shifting فقط رکوردهای همان PackageId** | **🆕** |
| **T3.7** | تست محاسبات با داده واقعی (۲ پکیج موازی، carryover مجزا) | 🔄 |
### فاز ۴ — UI ≈ ۵ روز
| تسک | شرح | v3? |
|-----|----- |-----|
| **T4.1** | FrontOffice: کاشی‌های پکیج (داینامیک) — ✅ `PackagePurchaseDialog.razor` | |
| **T4.2** | FrontOffice: مدال پرداخت (دایا+مستقیم / فقط مستقیم) — ✅ Step 2 in dialog | |
| **T4.3** | FrontOffice: MyPackages — re-purchase | |
| **T4.4** | FrontOffice: ActivationSection + Contract — داینامیک | |
| **T4.5** | BackOffice: CRUD پکیج — فیلدهای جدید (۱۱ فیلد) | 🔄 |
| **T4.6** | BackOffice: ماتریس PackageFeature | |
| **T4.7** | BackOffice: ActivateClubDialog — dropdown | |
| **T4.8** | **FrontOffice: CommissionDashboard — کارت‌های خلاصه per-package + مجموع پاداش** | **🆕** |
| **T4.9** | **FrontOffice: WeeklyBalance — تعادل تیم اول/دوم per-package** | **🆕** |
| **T4.10** | **BackOffice: Commission Dashboard — فیلتر dropdown پکیج** | **🆕** |
| **T4.11** | **BackOffice: Weekly Reports + CSV export — ستون پکیج + فیلتر** | **🆕** |
| **T4.12** | **BackOffice: BalancesReport + UserPayouts — ستون پکیج + فیلتر** | **🆕** |
| **T4.13** | **BackOffice: Package Create/Edit — Quick Access فیچرها (checkbox inline)** | **🆕** |
| **T4.14** | **FrontOffice: MagicWalletChargePage — حذف ×۲.۵ و سقف hardcoded (۶ جا) → خواندن از API** | **🆕 v4** |
| **T4.15** | **FrontOffice: حذف "پکیج طلایی" hardcoded (۵+ جا) + حذف PackageId=1** | **🆕 v4** |
| **T4.16** | **FrontOffice: قرارداد حقوقی — مبلغ + نام پکیج داینامیک (مسئولیت حقوقی!)** | **🆕 v4** |
| **T4.17** | **BackOffice: ManualActivationDialog — حذف 56M hardcoded + اضافه package selector** | **🆕 v4** |
| **T4.18** | **BackOffice: SystemConfiguration — تفکیک تنظیمات global/per-package** | **🆕 v4** |
### فاز ۵ — تست و استقرار ≈ ۳ روز
| تسک | شرح | v3? |
|-----|----- |-----|
| **T5.1** | تست خرید هر پکیج | |
| **T5.2** | تست re-purchase بعد تکمیل چرخه | |
| **T5.3** | تست Commission Pool جداگانه | |
| **T5.4** | تست Migration | |
| **T5.5** | **تست تعادل per-package: carryover مجزا، MaxBalancesPerLeg متفاوت** | **🆕** |
| **T5.6** | **تست گزارش FO per-package: مشتری breakdown صحیح می‌بیند** | **🆕** |
| **T5.7** | **تست گزارش BO per-package: فیلتر پکیج + CSV** | **🆕** |
| **T5.8** | **تست Magic Wallet: سقف متفاوت per-package (پایه=1B, نقره‌ای=100M) — EXIT صحیح** | **🆕 v4** |
| **T5.9** | **تست ضریب جادویی: پکیج A ×2.5 vs پکیج B ×2.0 — اعتبار صحیح** | **🆕 v4** |
| **T5.10** | **تست قرارداد حقوقی: مبلغ و نام پکیج صحیح در متن** | **🆕 v4** |
| **T5.11** | **تست WalletChangeLog: رکوردها PackageId دارند** | **🆕 v4** |
| **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 |
---
## ۱۲. ریسک‌ها (v5)
| ریسک | احتمال | شدت | راه‌حل |
|------|--------|-----|--------|
| Migration داده‌ها — PackageId اشتباه | کم | بحرانی | Verify query + بکاپ |
| SP تغییر → محاسبات اشتباه | متوسط | بحرانی | تست staging قبل production |
| ادغام handlers → رگرسیون | متوسط | زیاد | E2E test |
| خرید مجدد بدون تکمیل چرخه | کم | زیاد | Validation: چرخه قبلی MagicCompletedAt |
| Proto breaking change | قطعی | کم | backward compatible fields |
| **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: حفظ — باید یکسان شوند** |
---
## ۱۳. تخمین زمانی (v5)
| فاز | مدت | وابستگی | v5 تغییر |
|-----|------|---------|----------|
| فاز ۰ — فیکس باگ‌ها | ۱ روز | — | |
| فاز ۱ — زیرساخت | **۵ روز** | فاز ۰ | ClubMembership: ۴ فیلد First/Last (Q21) + Migration ActivatedAt |
| فاز ۲ — منطق | **۶ روز** | فاز ۱ | +۱ (FeatureDiffService + Skip قرارداد + First/Last Activation) |
| فاز ۳ — پورسانت | ۴ روز | فاز ۱ | LastActivationDate برای تشخیص هفتگی (Q22) |
| فاز ۴ — UI | **۷ روز** | فاز ۲ | |
| فاز ۵ — تست | **۵ روز** | فاز ۳, ۴ | +۱ (تست قرارداد یک‌بار + فیچر DIFF + First/Last + carryover تغییر پکیج) |
| **مجموع** | **~۲۸ روز** | | |
> فاز ۲ و ۳ **موازی** → مسیر بحرانی: ۰→۱→۲→۴→۵ = **~۲۴ روز**
> نسبت به v4 (**۲۲ روز**): **+۲ روز** بخاطر FeatureDiffService + قرارداد یک‌بار + First/Last Activation
> نسبت به v3 (**۱۷ روز**): **+۷ روز** — بزرگ‌ترین سهم: Magic Wallet + WalletChangeLog + فیچر DIFF + First/Last
---
## ۱۴. تصمیمات جدید v6 (Q24–Q30)
> **تاریخ:** ۸ اسفند ۱۴۰۴
> **زمینه:** مرور نهایی قبل از تست و استقرار — ۷ نکته جدید شناسایی شد
### Q24 — آستانه موجودی برای ورود به Magic و خرید مجدد 🔴
> **مشکل:** شرط فعلی `wallet.Balance == 0` عملاً غیرممکنه — چون قیمت محصولات متفاوته (مثلاً ۳۵۰,۰۰۰ تومان، ۱,۲۰۰,۰۰۰ تومان، ...) کاربر هیچ‌وقت نمی‌تونه دقیقاً به صفر برسه.
**تصمیم:** آستانه ثابت **۱,۰۰۰,۰۰۰ ریال (۱۰۰,۰۰۰ تومان)** — هم برای ورود به Magic، هم برای تشخیص تکمیل چرخه.
**مکان‌های تاثیر:**
| # | فایل | شرط فعلی | شرط جدید |
|---|------|----------|----------|
| 1 | `UserOrderService.cs` L342 | `if (wallet.Balance == 0)` — ورود به Magic | `if (wallet.Balance <= MagicWalletEntryThreshold)` |
| 2 | `UserOrderService.cs` L342 | `if (wallet.Balance == 0)` — EXIT Magic check | `if (wallet.Balance <= MagicWalletEntryThreshold)` |
| 3 | خرید مجدد guard | `PackagePurchaseMethod == None` | + `Balance <= Threshold` |
**تعریف ثابت:**
```csharp
// SystemConstants.cs یا Package entity
public const long MagicWalletEntryThreshold = 1_000_000; // 100,000 تومان = 1,000,000 ریال
```
**فلوی جدید:**
```
کاربر خرید می‌کند → Balance = 850,000 ریال (کمتر از 1M)
→ سیستم: Balance <= 1,000,000 ✅ → ورود به Magic Mode
→ قبلاً: Balance == 0 ❌ → کاربر گیر می‌افتاد!
Magic تکمیل → Balance = 200,000 ریال
→ سیستم: Balance <= 1,000,000 ✅ → EXIT Magic → مجاز به خرید مجدد
→ قبلاً: Balance == 0 ❌ → کاربر تا ابد در Magic!
```
> ⚠️ **نکته:** این آستانه **ثابت (global)** تعریف می‌شه — نه per-package. چون مربوط به قیمت محصولات فروشگاهه، نه پکیج.
---
### Q25 — DayaLoans فقط پکیج پایه
> **تایید:** وام دایا فعلاً فقط برای پکیج پایه فعاله و تغییر نمی‌کنه.
**وضعیت فعلی:**
- فیلد `Package.SupportsDayaPurchase` روی پکیج پایه = `true`، نقره‌ای = `false`
- `CheckAndProcessDayaLoansCommandHandler` فقط پکیج‌هایی که `SupportsDayaPurchase = true` دارن رو بررسی می‌کنه
- این یک تصمیم بیزینسی ثابته — اگر در آینده تغییر کرد، ادمین از BackOffice فلگ رو تغییر می‌ده
---
### Q26 — SP Worker (مدیریت خودکار Stored Procedures) 🔴
> **مشکل:** الان SPها **کاملاً دستی** deploy می‌شن. اگه SP تغییر کنه، باید یادمون باشه دستی اجرا کنیم.
**تصمیم:** یک `IHostedService` به نام `StoredProcedureDeploymentService` ایجاد بشه.
**الگوریتم:**
```
1. Startup → خواندن فایل‌های .sql از Embedded Resources
2. برای هر فایل:
a. محاسبه SHA256 checksum محتوا
b. خواندن checksum قبلی از جدول "CMS"."StoredProcedureVersions"
c. اگر وجود نداشت یا checksum فرق داشت:
→ اجرای CREATE OR ALTER PROCEDURE
→ ذخیره checksum جدید
→ لاگ: "SP {name} deployed/updated"
d. اگر checksum برابر بود:
→ لاگ: "SP {name} is up-to-date, skipping"
3. اجرا بعد از EF Migration (ترتیب startup)
```
**جدول جدید:**
```sql
CREATE TABLE "CMS"."StoredProcedureVersions" (
"Id" BIGSERIAL PRIMARY KEY,
"Name" VARCHAR(256) NOT NULL UNIQUE, -- نام SP
"Checksum" VARCHAR(64) NOT NULL, -- SHA256 hash
"DeployedAt" TIMESTAMP NOT NULL, -- آخرین deploy
"Version" INT NOT NULL DEFAULT 1 -- شمارنده نسخه
);
```
**فایل‌های SP فعلی (embed شوند):**
| # | فایل | خطوط |
|---|------|------|
| 1 | `sp_CalculateWeeklyBalances.sql` | ۳۷۲ |
| 2 | `sp_CalculateWeeklyCommissionPool.sql` | ۲۶۷ |
**csproj تغییرات:**
```xml
<ItemGroup>
<EmbeddedResource Include="Persistence\StoredProcedures\*.sql" />
</ItemGroup>
```
---
### Q27 — History Tables — یکسان‌سازی و ثبت خودکار 🟡
> **مشکل:** نام‌گذاری history tableها ناسازگاره و ثبت تاریخچه دستیه.
**وضعیت فعلی:**
| Master Entity | History Entity | نام‌گذاری |
|---------------|---------------|----------|
| `UserCommissionPayout` | `CommissionPayoutStatusHistory` | ❌ نام متفاوت |
| `CommissionCashWithdrawal` | `CommissionCashWithdrawalStatusHistory` | ✅ مشابه |
| `DiscountOrder` | `DiscountOrderStatusHistory` | ✅ مشابه |
| `UserWallet` | `UserWalletChangeLog` | ❌ نام متفاوت |
| `Package` | ❌ ندارد | 🔴 |
| `ClubMembership` | ❌ ندارد | 🔴 |
| `ClubMembershipCycle` | ❌ ندارد | 🔴 |
**تصمیم — ۲ بخش:**
**بخش ۱: نام‌گذاری یکسان**
```
الگو: {MasterEntityName}History
مثال:
UserCommissionPayout → UserCommissionPayoutHistory (rename از CommissionPayoutStatusHistory)
UserWallet → UserWalletHistory (rename از UserWalletChangeLog)
Package → PackageHistory (جدید)
ClubMembership → ClubMembershipHistory (جدید)
ClubMembershipCycle → ClubMembershipCycleHistory (جدید)
```
**بخش ۲: ثبت خودکار تغییرات**
```
دو رویکرد:
A) EF Interceptor (پیشنهادی):
→ در SaveChangesInterceptor، entity‌های تغییریافته شناسایی
→ برای هر entity که IHasHistory پیاده کرده:
snapshot فعلی → رکورد History جدید
→ مزیت: یکجا و خودکار
B) Domain Events:
→ هر entity یک EntityChangedEvent publish کنه
→ Handler مربوطه History ثبت کنه
→ مزیت: async + decoupled
```
**Interface پیشنهادی:**
```csharp
public interface IHasHistory<THistory> where THistory : BaseHistoryEntity
{
THistory CreateHistorySnapshot(string action, long? changedBy);
}
public abstract class BaseHistoryEntity : BaseEntity
{
public long MasterEntityId { get; set; } // FK به entity اصلی
public string Action { get; set; } // Created, Updated, StatusChanged, ...
public long? ChangedBy { get; set; } // UserId تغییردهنده
public DateTime ChangedAt { get; set; } // زمان تغییر
public string? Snapshot { get; set; } // JSON snapshot (اختیاری)
}
```
---
### Q28 — UI Guidance (آموزش و هشدار در FO/BO) 🟡
> **تصمیم:** در سراسر FrontOffice و BackOffice، متن‌های آموزشی و هشداری اضافه بشه.
**انواع Guidance:**
| نوع | جایگاه | مثال |
|-----|--------|------|
| **مودال آموزشی** | اولین بار باز کردن صفحه | «سیستم پکیج‌بیس: شما می‌توانید بعد از تکمیل چرخه، پکیج جدید بخرید» |
| **متن inline** | کنار فیلد/آیتم | «💡 هزینه فعال‌سازی از قیمت پکیج محاسبه می‌شود» |
| **هشدار** | قبل از اقدام بحرانی | «⚠️ با تغییر پکیج، فیچرهای قبلی ممکنه غیرفعال بشن» |
| **Tooltip** | روی آیکون اطلاعات | «این مبلغ بر اساس پکیج فعال شما محاسبه شده» |
**صفحات هدف FrontOffice:**
| # | صفحه | نوع | محتوا |
|---|-------|-----|-------|
| G1 | صفحه پکیج‌ها | مودال (اولین بار) | توضیح سیستم پکیج‌بیس، تفاوت پکیج‌ها، نحوه خرید |
| G2 | Checkout | متن inline | «این پکیج {features} را شامل می‌شود» |
| G3 | MyPackages | متن inline | «بعد تکمیل چرخه جادویی، می‌توانید پکیج جدید بخرید» |
| G4 | Magic Wallet | هشدار مودال | «سقف واریز و ضریب بر اساس پکیج شماست» |
| G5 | Commission Dashboard | tooltip | «پاداش بر اساس هر پکیج جداگانه محاسبه می‌شود» |
| G6 | قرارداد باشگاه | متن inline | «این قرارداد فقط یک‌بار امضا می‌شود» |
| G7 | ActivationSection | متن inline | «هزینه تخمینی بر اساس پکیج {name} محاسبه شده» |
**صفحات هدف BackOffice:**
| # | صفحه | نوع | محتوا |
|---|-------|-----|-------|
| G8 | Package CRUD | متن inline | «تغییر قیمت روی کاربران فعلی اثر ندارد — فقط خریدهای جدید» |
| G9 | Feature Matrix | tooltip | «فیچرهای تیک‌خورده برای خریداران این پکیج فعال می‌شود» |
| G10 | Manual Payment | هشدار | «مبلغ پرداخت باید مطابق قیمت پکیج انتخابی باشد» |
| G11 | Commission Reports | متن inline | «هر پکیج Pool پورسانت مستقل دارد» |
| G12 | User Detail | متن inline | «پکیج فعلی: {name} — چرخه: {n}» |
| G13 | Club Members | tooltip | «فیلتر بر اساس آخرین پکیج خریداری‌شده» |
**پیاده‌سازی فنی:**
```razor
@* FrontOffice — کامپوننت Guidance قابل استفاده مجدد *@
<PackageGuidance
Page="Packages"
Type="Modal"
ShowOnce="true"
Title="سیستم پکیج‌بیس"
Content="@_guidanceContent" />
@* BackOffice — متن inline *@
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
💡 تغییر قیمت روی کاربران فعلی اثر ندارد — فقط خریدهای جدید
</MudAlert>
```
---
### Q29 — شرط EXIT Magic — تایید: آخرین پکیج فعال
> **تایید:** شرط EXIT از `ClubMembershipCycle` فعلی (`IsCurrentCycle=true`) → `PackageId` → `Package.MagicWalletMaxDeposit` خوانده می‌شه.
**فلوی دقیق:**
```
1. UserOrderService.ProcessOrderAsync → wallet.Balance کم می‌شه
2. if (Balance <= Threshold): ← Q24 آستانه جدید
a. اگر Normal + خرید کرده + باشگاه فعال → ENTER Magic
b. اگر Magic:
→ خواندن چرخه فعلی → PackageId → Package.MagicWalletMaxDeposit
→ if (MagicTotalDeposited >= MaxDeposit) → EXIT Magic
→ else: هنوز Magic (می‌تونه شارژ کنه)
```
> **Fallback:** اگه چرخه فعالی نبود → `IsBasePackage = true` (پکیج پایه)
---
### Q30 — Carryover — توضیح ساده (v6 اصلاح‌شده)
> **Carryover = باقی‌مانده تعادل از هفته قبل — بر اساس پکیج زیرمجموعه‌ها**
**مثال ساده:**
```
علی بالاسری — زیرمجموعه‌هاش پکیج پایه دارن:
هفته ۱۰:
تیم چپ (پکیج پایه) = ۵۰ نفر، تیم راست (پکیج پایه) = ۳۰ نفر
تعادل_پایه = MIN(50, 30) = 30
باقی‌مانده_پایه: {چپ: 20, راست: 0} ← CARRYOVER
هفته ۱۱:
اعضای جدید (پکیج پایه): چپ = 5، راست = 10
جمع با carryover: چپ = 5+20 = 25، راست = 10+0 = 10
تعادل_پایه = MIN(25, 10) = 10
باقی‌مانده_پایه: {چپ: 15, راست: 0} ← CARRYOVER بعدی
```
**تغییر پکیج خود کاربر — تاثیری ندارد (Q23 اصلاحی):**
```
علی پکیج خودش رو عوض می‌کنه (پایه → نقره‌ای):
carryover_پایه = {چپ: 20, راست: 0} ← هنوز هست! حذف نمی‌شه!
carryover_نقره‌ای = {چپ: 0, راست: 0}
✅ هفته بعد، علی هنوز از Pool پایه سود می‌بره
(چون زیرمجموعه‌هایی با پکیج پایه داره)
✅ همزمان از Pool نقره‌ای هم سود می‌بره
(چون زیرمجموعه‌هایی با پکیج نقره‌ای هم داره)
پکیج خود علی فقط تعیین می‌کنه:
→ ActivationFee‌اش به کدوم Pool بره
→ MagicWallet سقفش چقدره
```