# 🪄 کیف‌پول جادویی (Magic Wallet) > **وضعیت:** طراحی — آماده پیاده‌سازی > **تاریخ:** اسفند ۱۴۰۴ > **وابستگی:** خرید پکیج پایه، فروشگاه عادی، سیستم کمیسیون --- ## ۱. خلاصه بیزینسی کاربر بعد از خرید پکیج پایه (۵۶M) و خرج کردن کامل Balance از فروشگاه عادی، وارد **حالت جادویی** می‌شه. در این حالت می‌تونه کیف‌پولش رو از درگاه شارژ کنه و **۲.۵ برابر** اعتبار بگیره — بدون هیچ کمیسیون یا پورسانتی. > ⚠️ **سقف ۱۰۰M ورودی / ۲۵۰M خروجی per-cycle هست** — هر بار که کاربر دوباره پکیج ۵۶M بخره و وارد Magic بشه، سقف ریست میشه. این چرخه تا بی‌نهایت تکرار میشه. ```mermaid 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) ### ۶.۱ فلوی کامل ```mermaid 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:
WalletMode == Magic
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
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 — فیلدهای جدید ```csharp // اضافه به 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 (جدید) ```csharp public enum WalletMode { Normal = 0, // حالت عادی — کمیسیون فعال Magic = 1 // حالت جادویی — شارژ ×2.5، بدون کمیسیون } ``` ### ۷.۳ TransactionType — مقادیر جدید ```csharp // اضافه به TransactionType enum: MagicWalletDeposit = 14, // واریز از درگاه (مبلغ واقعی) MagicWalletBonus = 15 // بونوس داخلی (مبلغ × 1.5) ``` ### ۷.۴ SystemConstants — ثابت‌های جدید ```csharp 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` ایجاد میشه. محاسبه کمیسیون از این جدول استفاده میکنه. ```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 ├── 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 اضافات ```protobuf // 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; } ```