docs(biz): v5 — قرارداد یک‌بار (Q19) + فیچر DIFF (Q20) + First/Last Activation (Q21-Q22) + carryover تغییر پکیج (Q23)

تغییرات بنیادی v5:
- Q19: قرارداد باشگاه فقط یک بار امضا — حذف re-contract از G5, A10, T2.7
- Q20: فیچرها DIFF/تفاضل — FeatureDiffService جدید (مقایسه + اعمال اختلاف)
- Q21: ClubMembership: ActivatedAt → FirstActivationDate + LastActivationDate + FirstPackageId + LastPackageId
- Q22: تشخیص فعال‌شدگان هفته از LastActivationDate
- Q23: carryover strictly per-package — تغییر پکیج = carryover قبلی شمرده نمی‌شود

بخش‌های جدید:
- 5.4: قرارداد یک‌بار + فلوچارت خرید مجدد بدون قرارداد
- 5.5: الگوریتم DIFF فیچرها + مثال عملی + کد پیشنهادی
- 5.6: تشخیص فعال‌شدگان هفته (SQL)
- ClubMembership entity v5 با ۴ فیلد جدید

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