Files
docs/roadmap/MAGIC-WALLET-SPEC.md
masoodafar-web 6b6173e2be docs: rename 'تخفیفی' to 'اعتباری' across all documentation
- 9 files updated: BUSINESS-01/02/03/04, TECH-02, OVERVIEW-01/03/04, MAGIC-WALLET-SPEC
- فروشگاه تخفیفی → فروشگاه اعتباری
- کیف‌پول تخفیفی → کیف‌پول اعتباری
- Consistent naming with FrontOffice UI
2026-02-22 20:57:05 +03:30

24 KiB
Raw Permalink Blame History

🪄 کیف‌پول جادویی (Magic Wallet)

وضعیت: طراحی — آماده پیاده‌سازی
تاریخ: اسفند ۱۴۰۴
وابستگی: خرید پکیج پایه، فروشگاه عادی، سیستم کمیسیون


۱. خلاصه بیزینسی

کاربر بعد از خرید پکیج پایه (۵۶M) و خرج کردن کامل Balance از فروشگاه عادی، وارد حالت جادویی می‌شه. در این حالت می‌تونه کیف‌پولش رو از درگاه شارژ کنه و ۲.۵ برابر اعتبار بگیره — بدون هیچ کمیسیون یا پورسانتی.

⚠️ سقف ۱۰۰M ورودی / ۲۵۰M خروجی per-cycle هست — هر بار که کاربر دوباره پکیج ۵۶M بخره و وارد Magic بشه، سقف ریست میشه. این چرخه تا بی‌نهایت تکرار میشه.

stateDiagram-v2
    [*] --> NewUser: ثبت‌نام
    NewUser --> Normal: خرید پکیج 56M\n(IPG یا دایا)
    Normal --> Magic: Balance = 0\n(همه رو خرج کرد)
    Magic --> PostMagic: Balance = 0\n(250M رو خرج کرد)
    PostMagic --> Normal: خرید مجدد پکیج 56M\n(فقط IPG — دایا ❌)
    Normal --> Magic: Balance = 0\n(دوباره خرج کرد)

    state Normal {
        [*] --> خرید_عادی
        خرید_عادی: Balance -= مبلغ خرید
        خرید_عادی --> کمیسیون_فعال
        کمیسیون_فعال: ✅ پورسانت + 25.2M Pool
    }

    state Magic {
        [*] --> شارژ_جادویی
        شارژ_جادویی: واریز × 2.5 = اعتبار
        شارژ_جادویی --> خرید_با_اعتبار
        خرید_با_اعتبار: Balance -= مبلغ خرید
        خرید_با_اعتبار --> بدون_کمیسیون
        بدون_کمیسیون: ❌ هیچ پورسانتی آزاد نمیشه
    }

۲. چرخه کامل کیف‌پول

فاز ۱ — خرید پکیج (Normal Mode)

مرحله عملیات نتیجه
۱ کاربر پکیج ۵۶M می‌خره (IPG یا دایا) Balance += 56M, DiscountBalance += 112M
۲ فعال‌سازی باشگاه 25.2M → Pool, فیچرها باز میشه
۳ کمیسیون هفتگی NetworkBalance += سهم
۴ خرید از فروشگاه عادی Balance -= مبلغ
۵ Balance = 0 → ورود به Magic Mode

فاز ۲ — کیف‌پول جادویی (Magic Mode)

مرحله عملیات نتیجه
۱ کاربر از صفحه شارژ جادویی مبلغ واریز میکنه درگاه ZarinPal
۲ تراکنش واریز ثبت میشه (مبلغ اصلی) Transaction(MagicDeposit, 10M)
۳ اعتبار ×2.5 به Balance اضافه میشه Balance += 25M
۴ تراکنش بونوس ثبت میشه Transaction(MagicBonus, 15M)
۵ لاگ کیف‌پول ثبت میشه WalletChangeLog (اجباری)
۶ MagicTotalDeposited += مبلغ واریزی ترک سقف
۷ خرید از فروشگاه عادی Balance -= مبلغ
۸ Balance = 0 و سقف پر شده → خروج از Magic Mode

فاز ۳ — بازگشت (Post-Magic)

مرحله عملیات نتیجه
۱ کیف‌پول جادویی تمام شد WalletMode = Normal
۲ برای ادامه باید دوباره پکیج ۵۶M بخره فقط IPG (دایا )
۳ خرید مجدد پکیج Balance += 56M, DiscountBalance += 112M
۴ همه آپشن‌ها دوباره فعال کمیسیون , Pool
۵ دوباره Balance = 0 بشه → Magic Mode مجدد

۳. قوانین Magic Mode

۳.۱ ضریب و سقف (per-cycle)

پارامتر مقدار ثابت پیشنهادی اسکوپ
ضریب شارژ ×2.5 MagicWalletMultiplier = 2.5m
سقف ورودی 100M تومان (1B ریال) MagicWalletMaxDeposit = 1_000_000_000 هر دور
سقف خروجی 250M تومان (2.5B ریال) MagicWalletMaxCredit = 2_500_000_000 هر دور
سود کاربر 150%

🔄 سقف per-cycle هست نه lifetime. هر بار که کاربر از Magic خارج بشه و دوباره پکیج ۵۶M بخره، MagicTotalDeposited و MagicTotalCredited به صفر ریست میشن و یه دور جدید شروع میشه.

۳.۲ مثال عددی (یک شارژ)

واریز: 10,000,000 تومان (100M ریال)
  ├── تراکنش واریز:    10,000,000 تومان   (Transaction: MagicDeposit)
  ├── بونوس داخلی:     15,000,000 تومان   (Transaction: MagicBonus)  
  ├── اعتبار نهایی:    25,000,000 تومان   (Balance += 250M ریال)
  └── WalletChangeLog:  BalanceChange = +250,000,000 ریال ✅

سقف (این دور):
  ├── مجموع واریزی:    MagicTotalDeposited += 100,000,000 ریال
  ├── مجموع اعتبار:    MagicTotalCredited  += 250,000,000 ریال  
  └── باقیمانده سقف:    MaxDeposit - TotalDeposited

۳.۳ مثال چند دوری (چرخه تکرار)

══════════════════════════════════════════════════════════════
  دور ۱ (اولین بار)
══════════════════════════════════════════════════════════════
  ① خرید پکیج 56M (IPG یا دایا) → Balance=56M, Discount=112M
  ② فعال‌سازی باشگاه → کمیسیون ✅
  ③ خرید از فروشگاه عادی → Balance کم میشه...
  ④ Balance = 0 → 🪄 Magic Mode فعال!
     MagicTotalDeposited = 0 (ریست)
  ⑤ شارژ جادویی: مجموعاً 100M واریز → 250M اعتبار
  ⑥ خرید از فروشگاه عادی → Balance کم میشه...
  ⑦ Balance = 0 → خروج از Magic → Normal Mode

══════════════════════════════════════════════════════════════
  دور ۲ (خرید مجدد پکیج — فقط IPG، دایا ❌)
══════════════════════════════════════════════════════════════
  ① خرید پکیج 56M (فقط IPG) → Balance=56M, Discount=112M
  ② کمیسیون دوباره فعال ✅
  ③ خرید از فروشگاه عادی → Balance کم میشه...
  ④ Balance = 0 → 🪄 Magic Mode فعال!
     MagicTotalDeposited = 0 (ریست)
                          ─────────
  ⑤ شارژ جادویی: مجموعاً 100M واریز → 250M اعتبار
  ⑥ خرید → Balance = 0 → خروج از Magic

══════════════════════════════════════════════════════════════
  دور ۳, ۴, ۵, ... (تا بی‌نهایت — همین چرخه تکرار)
══════════════════════════════════════════════════════════════

۳.۴ چه چیزهایی غیرفعال میشه

قابلیت Normal Mode Magic Mode
خرید از فروشگاه عادی
خرید از فروشگاه اعتباری (DiscountBalance قبلی)
کمیسیون هفتگی
پورسانت ۲۵.۲M
شارژ جادویی ×2.5
خرید مجدد پکیج

۴. شرایط ورود و خروج

۴.۱ ورود به Magic Mode

شرط‌ها (همه باید true باشن):
  ├── wallet.Balance == 0                       (کیف‌پول خالی شد)
  ├── user.PackagePurchaseMethod != None         (قبلاً پکیج خریده)
  ├── wallet.WalletMode == Normal                (الان عادیه)
  └── user.ClubMembership.IsActive == true       (باشگاه فعاله)

نتیجه:
  ├── wallet.WalletMode = Magic
  ├── wallet.MagicActivatedAt = DateTime.UtcNow
  ├── wallet.MagicTotalDeposited = 0
  └── wallet.MagicTotalCredited = 0

۴.۲ خروج از Magic Mode

شرط‌ها (هر دو باید همزمان true باشن):
  ├── wallet.Balance == 0                              (همه رو خرج کرده)
  └── wallet.MagicTotalDeposited >= MagicWalletMaxDeposit  (سقف 100M پر شده)

نتیجه:
  ├── wallet.WalletMode = Normal
  ├── wallet.MagicCompletedAt = DateTime.UtcNow
  └── user.PurchaseCycleCount++

⚠️ توضیح مهم:
  اگه Balance=0 بشه ولی هنوز سقف شارژ پر نشده → هنوز Magic هست!
  کاربر میتونه دوباره شارژ کنه (تا سقف 100M).
  
  مثال:
    TotalDeposited = 50M, Balance = 0
    → هنوز Magic → میتونه 50M دیگه شارژ کنه (125M اعتبار بگیره)
    
    TotalDeposited = 100M, Balance = 30M
    → هنوز Magic → نمیتونه شارژ کنه ولی هنوز بالانس داره
    
    TotalDeposited = 100M, Balance = 0
    → ✅ خروج از Magic → Normal Mode

۴.۳ ریست سقف در دور بعدی

وقتی کاربر دوباره پکیج ۵۶M بخره و Balance=0 بشه → Magic Mode:
  ├── MagicTotalDeposited = 0    ← ریست!
  ├── MagicTotalCredited = 0     ← ریست!
  ├── MagicActivatedAt = now     ← زمان جدید
  └── MagicCompletedAt = null    ← پاک میشه

⚠️ سقف per-cycle هست:
  ├── هر دور: حداکثر 100M واریز → 250M اعتبار
  ├── تعداد دور: بی‌نهایت (تا وقتی پکیج بخره)
  └── PurchaseCycleCount: فقط برای ترک تعداد دورها (محدودیت نداره)

۵. تراکنش‌ها و لاگ

۵.۱ انواع تراکنش جدید

TransactionType کد توضیح
MagicWalletDeposit 14 واریز اصلی از درگاه (مبلغ واقعی)
MagicWalletBonus 15 بونوس داخلی (مبلغ × 1.5)

۵.۲ لاگ کیف‌پول (اجباری)

هر شارژ جادویی باید یک رکورد UserWalletChangeLog ایجاد کنه:

UserWalletChangeLog:
  ├── UserWalletId          = wallet.Id
  ├── CurrentBalance        = wallet.Balance (بعد از تغییر)
  ├── BalanceChange          = creditAmount (مبلغ × 2.5)
  ├── IsIncrement            = true
  ├── ReferenceId            = transaction.Id
  ├── CurrentDiscountBalance = wallet.DiscountBalance (بدون تغییر)
  ├── DiscountBalanceChange  = 0
  ├── CurrentNetworkBalance  = wallet.NetworkBalance (بدون تغییر)
  └── NetworkBalanceChange   = 0

⚠️ لاگ کیف‌پول دلخواه نیست — اجباریه. هر تغییر Balance باید لاگ بخوره.


۶. مسیر شارژ جادویی (API)

۶.۱ فلوی کامل

sequenceDiagram
    participant U as کاربر
    participant FO as FrontOffice
    participant CMS as CMS (gRPC)
    participant PYMS as PYMS
    participant ZP as ZarinPal

    U->>FO: مبلغ واریز (مثلاً 10M)
    FO->>CMS: InitiateMagicCharge(userId, amount)
    
    Note over CMS: Validations:<br/>WalletMode == Magic<br/>TotalDeposited + amount <= 100M

    CMS->>PYMS: CreatePaymentRequest(amount)
    PYMS->>ZP: Request Authority
    ZP-->>PYMS: Authority
    PYMS-->>CMS: PaymentUrl
    CMS-->>FO: PaymentUrl
    FO->>U: Redirect to ZarinPal

    U->>ZP: پرداخت
    ZP->>CMS: Callback /api/wallet/verify-magic-charge

    Note over CMS: creditAmount = amount × 2.5<br/>bonusAmount = amount × 1.5

    CMS->>CMS: Balance += creditAmount
    CMS->>CMS: Transaction #1 (MagicDeposit, amount)
    CMS->>CMS: Transaction #2 (MagicBonus, bonusAmount)
    CMS->>CMS: WalletChangeLog ✅
    CMS->>CMS: MagicTotalDeposited += amount

    CMS-->>FO: Redirect to callback page
    FO->>U: نتیجه + بالانس جدید

۶.۲ تفاوت با ChargeDiscountWallet

ویژگی ChargeDiscountWallet MagicCharge
هدف شارژ DiscountBalance شارژ Balance (جادویی)
ضریب ×1 (مبلغ واقعی) ×2.5
سقف ندارد 100M تومان ورودی
شرط همیشه فعال فقط WalletMode == Magic
کمیسیون غیرفعال
Callback /api/wallet/verify-discount-charge /api/wallet/verify-magic-charge
وضعیت ⚠️ نیمه‌کاره (controller ندارد) 🆕 باید ساخته بشه

⚠️ ChargeDiscountWallet ناقصه و باید مستقل کامل بشه — ربطی به کیف‌پول جادویی نداره.


۷. تغییرات مدل داده

۷.۱ UserWallet — فیلدهای جدید

// اضافه به UserWallet entity:
public WalletMode WalletMode { get; set; } = WalletMode.Normal;
public long MagicTotalDeposited { get; set; }    // مجموع واریزی واقعی (ریال)
public long MagicTotalCredited { get; set; }     // مجموع اعتبار داده‌شده (ریال)  
public DateTime? MagicActivatedAt { get; set; }
public DateTime? MagicCompletedAt { get; set; }

۷.۲ WalletMode enum (جدید)

public enum WalletMode
{
    Normal = 0,    // حالت عادی — کمیسیون فعال
    Magic = 1      // حالت جادویی — شارژ ×2.5، بدون کمیسیون
}

۷.۳ TransactionType — مقادیر جدید

// اضافه به TransactionType enum:
MagicWalletDeposit = 14,    // واریز از درگاه (مبلغ واقعی)
MagicWalletBonus = 15       // بونوس داخلی (مبلغ × 1.5)

۷.۴ SystemConstants — ثابت‌های جدید

public const decimal MagicWalletMultiplier = 2.5m;
public const long MagicWalletMaxDeposit = 1_000_000_000;   // 100M تومان = 1B ریال
public const long MagicWalletMaxCredit = 2_500_000_000;    // 250M تومان = 2.5B ریال

۷.۵ ClubMembershipCycle — جدول جدید (حل مشکل تاریخ کمیسیون)

مشکل فعلی

⚠️ الان محاسبه کمیسیون هفتگی از ClubMembership.ActivatedAt استفاده میکنه:

   WHERE cm.ActivatedAt >= @StartDate AND cm.ActivatedAt <= @EndDate

وقتی کاربر دور دوم پکیج بخره، ActivateClubMembership این تاریخ رو overwrite میکنه:
   entity.ActivatedAt = DateTime.Now;  // ← تاریخ اصلی از بین میره!

مشکل: تاریخ اولین فعال‌سازی باشگاه از دست میره.

راه‌حل: جدول ClubMembershipCycle

به‌جای آپدیت کردن ActivatedAt، هر بار که پکیج خریده میشه یک رکورد جدید در جدول ClubMembershipCycle ایجاد میشه. محاسبه کمیسیون از این جدول استفاده میکنه.

// 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
}

تغییرات در منطق کمیسیون

-- قبل (غلط — 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
  ├── ALTER TABLE UserWallets ADD WalletMode int NOT NULL DEFAULT 0
  ├── ALTER TABLE UserWallets ADD MagicTotalDeposited 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 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)

۸. تغییرات Handler‌ها

۸.۱ SubmitShopBuyOrderCommandHandler (تغییر)

بعد از کسر Balance:
  if (wallet.Balance == 0 
      && user.PackagePurchaseMethod != None
      && wallet.WalletMode == Normal
      && user.ClubMembership?.IsActive == true)
  {
      → ورود به Magic Mode
  }

  if (wallet.WalletMode == Magic 
      && wallet.Balance == 0 
      && wallet.MagicTotalDeposited >= SystemConstants.MagicWalletMaxDeposit)
  {
      → خروج از Magic Mode
      // سقف 100M پر شده + همه رو خرج کرده
  }
  // ⚠️ اگه Balance=0 ولی سقف پر نشده → هنوز Magic!
  // کاربر میتونه دوباره شارژ کنه

۸.۲ CalculateWeeklyBalancesCommandHandler (تغییر)

تغییر ۱ — فیلتر Magic:
  فقط کاربرهایی که wallet.WalletMode == Normal
  (کاربرهای 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 باید تغییر کنه)

۸.۳ 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 اضافه:
  if (user.PurchaseCycleCount > 0)
      → reject: "وام دایا فقط برای خرید اولین پکیج"

۸.۵ InitiateMagicChargeCommandHandler (جدید)

Input: UserId, Amount
Validations:
  ├── wallet.WalletMode == Magic
  ├── Amount > 0
  └── MagicTotalDeposited + Amount <= MagicWalletMaxDeposit
Action:
  ├── PaymentTransaction → PYMS → ZarinPal
  └── CallbackUrl = "/api/wallet/verify-magic-charge"
Return: PaymentUrl

۸.۶ VerifyMagicChargeCommandHandler (جدید)

Input: Authority, Status
On Success:
  ├── creditAmount = amount × 2.5
  ├── bonusAmount = creditAmount - amount
  ├── wallet.Balance += creditAmount
  ├── wallet.MagicTotalDeposited += amount
  ├── wallet.MagicTotalCredited += creditAmount
  ├── Transaction #1 (MagicWalletDeposit, amount)
  ├── Transaction #2 (MagicWalletBonus, bonusAmount)
  └── WalletChangeLog ✅ (اجباری)

۹. تغییرات UI (FrontOffice)

۹.۱ صفحات جدید

صفحه Route توضیح
MagicWallet.razor /profile/magic-wallet فرم شارژ + پروگرس‌بار سقف
MagicPaymentCallback.razor /profile/magic-payment-callback نتیجه پرداخت شارژ جادویی

۹.۲ تغییر صفحات موجود

صفحه تغییر
Profile/Index.razor بنر "🪄 کیف‌پول جادویی فعال" + لینک شارژ
Profile/Wallet.razor نمایش وضعیت Magic + پروگرس (deposited/100M)
Club membership page اگه Magic → پیام "بعد از اتمام جادویی می‌تونید پکیج بخرید"

۹.۳ gRPC Proto اضافات

// userwallet.proto — RPCهای جدید:
rpc InitiateMagicCharge (MagicChargeRequest) returns (MagicChargeResponse);
rpc GetMagicWalletStatus (MagicWalletStatusRequest) returns (MagicWalletStatusResponse);

message MagicChargeRequest {
    int64 user_id = 1;
    int64 amount = 2;
}

message MagicChargeResponse {
    string payment_url = 1;
    int64 remaining_deposit_cap = 2;
}

message MagicWalletStatusResponse {
    bool is_magic_mode = 1;
    int64 total_deposited = 2;
    int64 total_credited = 3;
    int64 remaining_cap = 4;
    string activated_at = 5;
}