- 9 files updated: BUSINESS-01/02/03/04, TECH-02, OVERVIEW-01/03/04, MAGIC-WALLET-SPEC - فروشگاه تخفیفی → فروشگاه اعتباری - کیفپول تخفیفی → کیفپول اعتباری - Consistent naming with FrontOffice UI
24 KiB
🪄 کیفپول جادویی (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;
}