docs: add ClubMembershipCycle table to preserve activation date

Problem: ActivateClubMembership overwrites ActivatedAt on re-purchase,
losing the original club activation date. Commission check uses
ActivatedAt to determine 'new member this week'.

Solution: New ClubMembershipCycle table
- Each package purchase creates a new cycle record
- ClubMembership.ActivatedAt = first-time only (never overwritten)
- Commission uses Cycle.PackagePurchasedAt instead of ActivatedAt
- IsCurrentCycle flag tracks active cycle
- Full history preserved for all purchase cycles

Also updated:
- SPEC: section 7.5 (entity), 7.6 (migration), 8.2 (SP change), 8.3 (handler)
- PLAN: phase 1 (entity), phase 2 (activate handler), phase 4 (date query)
- Checklist: added 4 new items
This commit is contained in:
masoodafar-web
2026-02-19 02:58:14 +03:30
parent b1dd69b31f
commit 0e61513b0e
2 changed files with 169 additions and 16 deletions
+30 -11
View File
@@ -19,10 +19,13 @@
├── TransactionType.cs → + MagicWalletDeposit=14, MagicWalletBonus=15 ├── TransactionType.cs → + MagicWalletDeposit=14, MagicWalletBonus=15
├── SystemConstants.cs → + MagicWalletMultiplier, MagicWalletMaxDeposit, MagicWalletMaxCredit ├── SystemConstants.cs → + MagicWalletMultiplier, MagicWalletMaxDeposit, MagicWalletMaxCredit
├── UserWalletConfiguration.cs → EF config برای فیلدهای جدید ├── UserWalletConfiguration.cs → EF config برای فیلدهای جدید
── Migration: AddMagicWalletFields → dotnet ef migrations add ── ClubMembershipCycle.cs → 🆕 entity جدید (حل مشکل تاریخ کمیسیون)
├── ClubMembershipCycleConfiguration.cs → EF config
├── Migration: AddMagicWalletFields → dotnet ef migrations add
└── Migration: AddClubMembershipCycle → dotnet ef migrations add + data seed
``` ```
**تست:** Migration اجرا بشه، فیلدها در DB ایجاد بشن، default‌ها درست باشن. **تست:** Migration اجرا بشه، فیلدها در DB ایجاد بشن، default‌ها درست باشن. هر ClubMembership موجود یه رکورد Cycle=1 داشته باشه.
--- ---
@@ -36,6 +39,10 @@
│ ├── بعد از کسر Balance: check ورود به Magic │ ├── بعد از کسر Balance: check ورود به Magic
│ └── بعد از کسر Balance: check خروج از Magic │ └── بعد از کسر Balance: check خروج از Magic
├── ActivateClubMembershipCommandHandler.cs
│ ├── ActivatedAt فقط بار اول ست بشه (دیگه overwrite نشه)
│ └── هر بار یک ClubMembershipCycle جدید اضافه بشه
├── (Optional) Domain Event: WalletModeChangedEvent ├── (Optional) Domain Event: WalletModeChangedEvent
│ └── برای لاگ و نوتیفیکیشن │ └── برای لاگ و نوتیفیکیشن
@@ -85,20 +92,28 @@
--- ---
### فاز ۴ — غیرفعال‌سازی کمیسیون (روز ۴.۵) ### فاز ۴ — غیرفعال‌سازی کمیسیون + تاریخ Cycle (روز ۴.۵)
**هدف:** کاربرهای Magic از کمیسیون خارج بشن **هدف:** کاربرهای Magic از کمیسیون خارج بشن + تاریخ کمیسیون از Cycle بخونه
``` ```
فایل‌های تغییری: فایل‌های تغییری:
├── CalculateWeeklyBalancesCommandHandler.cs ├── CalculateWeeklyBalancesCommandHandler.cs
── فیلتر: WHERE wallet.WalletMode != Magic ── فیلتر: WHERE wallet.WalletMode != Magic
│ └── تاریخ: ActivatedAt → ClubMembershipCycle.PackagePurchasedAt
── (یا SP اگه از Stored Procedure استفاده میکنه) ── sp_CalculateWeeklyBalances.sql
└── sp_CalculateWeeklyBalances → + JOIN UserWallets WHERE WalletMode = 0 ├── + JOIN UserWallets WHERE WalletMode = 0
│ └── WHERE cm.ActivatedAt → cc.PackagePurchasedAt (AND cc.IsCurrentCycle = 1)
└── WeekRepository (اگه date range query داره)
└── آپدیت query
``` ```
**تست:** کاربر Magic در محاسبات هفتگی شرکت نکنه ✅ **تست:**
- کاربر Magic در محاسبات هفتگی شرکت نکنه ✅
- کاربر دور ۲ (پکیج مجدد): با تاریخ PackagePurchasedAt جدید امتیاز بگیره ✅
- تاریخ اصلی ActivatedAt تغییر نکرده باشه ✅
--- ---
@@ -165,19 +180,23 @@
- [ ] **فاز ۱:** WalletMode enum - [ ] **فاز ۱:** WalletMode enum
- [ ] **فاز ۱:** UserWallet entity + 5 فیلد جدید - [ ] **فاز ۱:** UserWallet entity + 5 فیلد جدید
- [ ] **فاز ۱:** ClubMembershipCycle entity (جدید)
- [ ] **فاز ۱:** TransactionType + 2 مقدار - [ ] **فاز ۱:** TransactionType + 2 مقدار
- [ ] **فاز ۱:** SystemConstants + 3 ثابت - [ ] **فاز ۱:** SystemConstants + 3 ثابت
- [ ] **فاز ۱:** EF Configuration - [ ] **فاز ۱:** EF Configuration (UserWallet + ClubMembershipCycle)
- [ ] **فاز ۱:** Migration - [ ] **فاز ۱:** Migration: AddMagicWalletFields
- [ ] **فاز ۱:** Migration: AddClubMembershipCycle + data seed
- [ ] **فاز ۲:** Trigger ورود Magic (SubmitShopBuyOrder) - [ ] **فاز ۲:** Trigger ورود Magic (SubmitShopBuyOrder)
- [ ] **فاز ۲:** Trigger خروج Magic - [ ] **فاز ۲:** Trigger خروج Magic
- [ ] **فاز ۲:** ActivateClubMembership → ActivatedAt نگه‌داشته بشه + Cycle جدید
- [ ] **فاز ۲:** PurchaseCycleCount - [ ] **فاز ۲:** PurchaseCycleCount
- [ ] **فاز ۳:** InitiateMagicChargeCommand + Handler - [ ] **فاز ۳:** InitiateMagicChargeCommand + Handler
- [ ] **فاز ۳:** VerifyMagicChargeCommand + Handler - [ ] **فاز ۳:** VerifyMagicChargeCommand + Handler
- [ ] **فاز ۳:** MagicWalletController (HTTP callback) - [ ] **فاز ۳:** MagicWalletController (HTTP callback)
- [ ] **فاز ۳:** gRPC Proto + Service - [ ] **فاز ۳:** gRPC Proto + Service
- [ ] **فاز ۳:** ۲ تراکنش + ۱ لاگ (اجباری) - [ ] **فاز ۳:** ۲ تراکنش + ۱ لاگ (اجباری)
- [ ] **فاز ۴:** فیلتر کمیسیون (C# یا SP) - [ ] **فاز ۴:** فیلتر کمیسیون Magic (C# handler + SP)
- [ ] **فاز ۴:** تاریخ کمیسیون: ActivatedAt → Cycle.PackagePurchasedAt (C# + SP)
- [ ] **فاز ۵:** MagicWallet.razor - [ ] **فاز ۵:** MagicWallet.razor
- [ ] **فاز ۵:** MagicPaymentCallback.razor - [ ] **فاز ۵:** MagicPaymentCallback.razor
- [ ] **فاز ۵:** WalletService gRPC client - [ ] **فاز ۵:** WalletService gRPC client
+139 -5
View File
@@ -337,7 +337,87 @@ public const long MagicWalletMaxDeposit = 1_000_000_000; // 100M تومان =
public const long MagicWalletMaxCredit = 2_500_000_000; // 250M تومان = 2.5B ریال public const long MagicWalletMaxCredit = 2_500_000_000; // 250M تومان = 2.5B ریال
``` ```
### ۷.۵ EF Migration ### ۷.۵ ClubMembershipCycle — جدول جدید (حل مشکل تاریخ کمیسیون)
#### مشکل فعلی
```
⚠️ الان محاسبه کمیسیون هفتگی از ClubMembership.ActivatedAt استفاده میکنه:
WHERE cm.ActivatedAt >= @StartDate AND cm.ActivatedAt <= @EndDate
وقتی کاربر دور دوم پکیج بخره، ActivateClubMembership این تاریخ رو overwrite میکنه:
entity.ActivatedAt = DateTime.Now; // ← تاریخ اصلی از بین میره!
مشکل: تاریخ اولین فعال‌سازی باشگاه از دست میره.
```
#### راه‌حل: جدول `ClubMembershipCycle`
به‌جای آپدیت کردن `ActivatedAt`، هر بار که پکیج خریده میشه یک رکورد جدید در جدول `ClubMembershipCycle` ایجاد میشه. محاسبه کمیسیون از این جدول استفاده میکنه.
```csharp
// Entity جدید:
public class ClubMembershipCycle : BaseAuditableEntity
{
public long UserId { get; set; }
public User User { get; set; }
public long ClubMembershipId { get; set; }
public ClubMembership ClubMembership { get; set; }
public int CycleNumber { get; set; } // شماره دور (1, 2, 3...)
public DateTime PackagePurchasedAt { get; set; } // تاریخ خرید پکیج
public DateTime? MagicStartedAt { get; set; } // شروع Magic (Balance=0)
public DateTime? MagicCompletedAt { get; set; } // پایان Magic
public PackagePurchaseMethod PurchaseMethod { get; set; } // IPG یا Daya
public long PackageAmount { get; set; } // 56M
public bool IsCurrentCycle { get; set; } // فقط یکی true
}
```
#### تغییرات در منطق کمیسیون
```sql
-- قبل (غلط — ActivatedAt از بین میره):
WHERE cm.ActivatedAt >= @StartDate AND cm.ActivatedAt <= @EndDate
-- بعد (درست — از جدول Cycle):
WHERE cc.PackagePurchasedAt >= @StartDate
AND cc.PackagePurchasedAt <= @EndDate
AND cc.IsCurrentCycle = 1
```
#### ClubMembership — بدون تغییر ساختاری
```
ClubMembership:
├── ActivatedAt → تاریخ اولین فعال‌سازی (هرگز overwrite نمیشه ✅)
├── IsActive → وضعیت فعلی باشگاه
└── + Cycles (nav prop) → لیست دورها
```
#### مثال عملی
```
ClubMembership #42:
UserId = 100
ActivatedAt = 1403/10/15 ← اولین بار (حفظ میشه ✅)
IsActive = true
ClubMembershipCycles:
┌────┬──────┬───────────────────┬──────────────┬─────────────┐
│ Id │ Cycle│ PackagePurchasedAt│ PurchaseMethod│IsCurrentCycle│
├────┼──────┼───────────────────┼──────────────┼─────────────┤
│ 1 │ 1 │ 1403/10/15 │ DayaLoan │ false │
│ 2 │ 2 │ 1404/01/20 │ DirectIPG │ false │
│ 3 │ 3 │ 1404/04/05 │ DirectIPG │ true ✅ │
└────┴──────┴───────────────────┴──────────────┴─────────────┘
→ کمیسیون هفته 1404/04/05 تا 1404/04/11:
PackagePurchasedAt (دور ۳) = 1404/04/05 → ✅ در بازه هست → امتیاز میگیره
→ تاریخ اولین فعال‌سازی: 1403/10/15 → حفظ شده ✅
```
### ۷.۶ EF Migration
``` ```
Migration: AddMagicWalletFields Migration: AddMagicWalletFields
@@ -346,6 +426,23 @@ Migration: AddMagicWalletFields
├── ALTER TABLE UserWallets ADD MagicTotalCredited bigint NOT NULL DEFAULT 0 ├── ALTER TABLE UserWallets ADD MagicTotalCredited bigint NOT NULL DEFAULT 0
├── ALTER TABLE UserWallets ADD MagicActivatedAt datetime2 NULL ├── ALTER TABLE UserWallets ADD MagicActivatedAt datetime2 NULL
└── ALTER TABLE UserWallets ADD MagicCompletedAt datetime2 NULL └── ALTER TABLE UserWallets ADD MagicCompletedAt datetime2 NULL
Migration: AddClubMembershipCycle
├── CREATE TABLE ClubMembershipCycles (
│ Id bigint IDENTITY PRIMARY KEY,
│ UserId bigint NOT NULL FK → Users,
│ ClubMembershipId bigint NOT NULL FK → ClubMemberships,
│ CycleNumber int NOT NULL,
│ PackagePurchasedAt datetime2 NOT NULL,
│ MagicStartedAt datetime2 NULL,
│ MagicCompletedAt datetime2 NULL,
│ PurchaseMethod int NOT NULL,
│ PackageAmount bigint NOT NULL,
│ IsCurrentCycle bit NOT NULL DEFAULT 0,
│ + BaseAuditableEntity fields
│ )
└── Data Migration: INSERT یک رکورد Cycle=1 برای هر ClubMembership موجود
(PackagePurchasedAt = ClubMembership.ActivatedAt)
``` ```
--- ---
@@ -378,12 +475,49 @@ Migration: AddMagicWalletFields
### ۸.۲ CalculateWeeklyBalancesCommandHandler (تغییر) ### ۸.۲ CalculateWeeklyBalancesCommandHandler (تغییر)
``` ```
فیلتر اضافه: تغییر ۱فیلتر Magic:
فقط کاربرهایی که wallet.WalletMode == Normal فقط کاربرهایی که wallet.WalletMode == Normal
(کاربرهای Magic از محاسبه کمیسیون خارج میشن) (کاربرهای Magic از محاسبه کمیسیون خارج میشن)
تغییر ۲ — تاریخ از Cycle (به‌جای ActivatedAt):
قبل:
WHERE cm.ActivatedAt >= @StartDate AND cm.ActivatedAt <= @EndDate
بعد:
WHERE cc.PackagePurchasedAt >= @StartDate
AND cc.PackagePurchasedAt <= @EndDate
AND cc.IsCurrentCycle = 1
(هم در C# handler و هم در SP باید تغییر کنه)
``` ```
### ۸.۳ CheckAndProcessDayaLoansCommandHandler (تغییر) ### ۸.۳ ActivateClubMembershipCommandHandler (تغییر مهم)
```
قبل (غلط — تاریخ overwrite میشه):
entity.ActivatedAt = DateTime.Now;
بعد (درست):
// ActivatedAt فقط بار اول ست میشه:
if (entity.ActivatedAt == default)
entity.ActivatedAt = DateTime.Now;
// هر بار یه Cycle جدید:
var previousCycle = entity.Cycles.FirstOrDefault(c => c.IsCurrentCycle);
if (previousCycle != null)
previousCycle.IsCurrentCycle = false;
entity.Cycles.Add(new ClubMembershipCycle
{
CycleNumber = (previousCycle?.CycleNumber ?? 0) + 1,
PackagePurchasedAt = DateTime.Now,
PurchaseMethod = user.PackagePurchaseMethod,
PackageAmount = SystemConstants.BasePackageAmount,
IsCurrentCycle = true
});
```
### ۸.۴ CheckAndProcessDayaLoansCommandHandler (تغییر)
``` ```
Validation اضافه: Validation اضافه:
@@ -391,7 +525,7 @@ Validation اضافه:
→ reject: "وام دایا فقط برای خرید اولین پکیج" → reject: "وام دایا فقط برای خرید اولین پکیج"
``` ```
### ۸.۴ InitiateMagicChargeCommandHandler (جدید) ### ۸.۵ InitiateMagicChargeCommandHandler (جدید)
``` ```
Input: UserId, Amount Input: UserId, Amount
@@ -405,7 +539,7 @@ Action:
Return: PaymentUrl Return: PaymentUrl
``` ```
### ۸.۵ VerifyMagicChargeCommandHandler (جدید) ### ۸.۶ VerifyMagicChargeCommandHandler (جدید)
``` ```
Input: Authority, Status Input: Authority, Status