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
95 KiB
📦 سیستم مبتنی بر پکیج (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
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اضافه (داینامیک) - ❌
PackageTypeenum نیاز نیست (IsBasePackageکافیست)
۴.۲ Entity جدید — PackageFeature
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):
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) ← v3UserCommissionPayout→ 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)
۵.۱ چرخه حیات کامل
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)
// 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 انجام میشود.
۵.۳ نکات مهم
- دایا فقط دور اول — بعد از دور اول، فقط IPG مجاز
- هر خرید = Commission contribution — ActivationFee به Pool آن پکیج
- PackagePurchaseMethod ریست بعد تکمیل چرخه
- فیچرها بر اساس پکیج جدید — DIFF/تفاضل اعمال میشود (Q20)
۵.۴ قرارداد باشگاه — فقط یک بار (v5 — Q19)
⚠️ تغییر بنیادی v5: قرارداد باشگاه مشتریان فقط یک بار در طول عمر کاربر امضا میشود.
سناریو:
خرید اول (پکیج پایه):
✅ OTP + امضای قرارداد + فعالسازی باشگاه
✅ فیچرهای پکیج پایه فعال
✅ قرارداد ثبت شد → UserContract record
خرید دوم (پکیج پایه مجدد):
✅ پرداخت + شارژ wallet + Commission
❌ قرارداد مجدد نمیشود (Q19)
✅ فیچرها بدون تغییر (همان پکیج)
خرید سوم (پکیج نقرهای):
✅ پرداخت + شارژ wallet + Commission
❌ قرارداد مجدد نمیشود (Q19)
✅ فیچر DIFF: حذف فیچرهای پایهای که نقرهای ندارد + اضافه فیچرهای جدید نقرهای
فلوی خرید مجدد (بدون قرارداد):
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 جدید ساخته میشه
کد پیشنهادی:
// 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)
-- کاربرانی که در هفته جاری فعال/خرید مجدد کردهاند:
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 — تغییرات ساختاری:
-- قبل (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 — تغییرات:
// قبل: فقط 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)
// 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):
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
۷.۱ ساخت پکیج جدید
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 |
پرداخت پورسانت |
اقدام لازم:
// اضافه به 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 جدید:
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)
// فعلی (اشتباه) — ActivateClubMembershipCommandHandler.cs + AcceptClubMembershipContractCommandHandler.cs:
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
// ↑ همیشه همه فیچرها (Chatika, Bime, Trip, Learn) — صرفنظر از پکیج!
مشکل ۱: پکیج نقرهای ممکنه فقط ۲ فیچر داشته باشه ولی سیستم فعلی همه ۴ فیچر رو فعال میکنه. مشکل ۲: در خرید مجدد، باید فقط تفاضل اعمال بشه (Q20) — نه تخصیص مجدد همه.
اقدام (v5):
// خرید اول (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
-- پکیج پایه (۵۶ میلیون تومان)
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 دادههای فعلی
-- ========================================
-- 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 (G1–G3) | |
| 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 |
تعریف ثابت:
// 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)
جدول جدید:
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 تغییرات:
<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 پیشنهادی:
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 | «فیلتر بر اساس آخرین پکیج خریداریشده» |
پیادهسازی فنی:
@* 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 سقفش چقدره