Refactor code structure for improved readability and maintainability

This commit is contained in:
masoodafar-web
2026-02-10 22:06:46 +03:30
parent 8f02cec22f
commit 5149b9a89c
182 changed files with 7305 additions and 243980 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+750
View File
@@ -0,0 +1,750 @@
# Club Discount Shop System - سیستم فروشگاه باشگاه مشتریان با تخفیف ترکیبی
**تاریخ ایجاد:** 2024-12-02
**تاریخ آپدیت:** 2024-12-02
**وضعیت:** طراحی (Phase 9)
**اولویت:** 🔴 بالا (یکی از دو فاز باقیمانده)
---
## 📋 فهرست
1. [مقدمه](#مقدمه)
2. [مفهوم اصلی: پرداخت ترکیبی](#مفهوم-اصلی-پرداخت-ترکیبی)
3. [تفاوت با Regular Shop](#تفاوت-با-regular-shop)
4. [معماری جداسازی](#معماری-جداسازی)
5. [Entity Design](#entity-design)
6. [Business Rules](#business-rules)
7. [تسک‌های پیاده‌سازی](#تسک-های-پیاده-سازی)
---
## 🎯 مقدمه
### هدف:
ایجاد **فروشگاه باشگاه مشتریان** که در آن کاربران می‌توانند با **پرداخت ترکیبی** خرید کنند:
**🔑 قانون اصلی**:
- کاربر **نمی‌تواند** کل محصول را فقط با `DiscountBalance` بخرد
- کاربر می‌تواند **درصدی از قیمت** را با `DiscountBalance` پرداخت کند
- **مابقی مبلغ** باید از طریق **درگاه پرداخت واقعی در Gateway/PYMS** پرداخت شود (نه در CMS)
### مثال عملی:
```
قیمت محصول: 1,000,000 تومان
حداکثر تخفیف مجاز: 30%
DiscountBalance کاربر: 500,000 تومان
محاسبه:
- حداکثر تخفیف قابل استفاده: 1,000,000 × 30% = 300,000 تومان
- DiscountBalance کاربر: 500,000 تومان (بیشتر از 300,000)
- مبلغ تخفیف نهایی: 300,000 تومان (محدود به 30%)
- مبلغ قابل پرداخت از درگاه: 1,000,000 - 300,000 = 700,000 تومان
نتیجه:
✅ کسر از DiscountBalance: 300,000 تومان
✅ پرداخت از درگاه: 700,000 تومان
✅ DiscountBalance باقیمانده: 200,000 تومان
```
---
## 🔄 مفهوم اصلی: پرداخت ترکیبی
### Flow خرید:
```
1. کاربر محصول را انتخاب می‌کند
2. سیستم چک می‌کند:
- قیمت محصول: X تومان
- حداکثر تخفیف مجاز: Y%
- DiscountBalance کاربر: Z تومان
3. محاسبه تخفیف:
MaxDiscountAmount = X × (Y / 100)
ActualDiscountAmount = Min(Z, MaxDiscountAmount)
4. محاسبه مبلغ درگاه:
GatewayAmount = X - ActualDiscountAmount
5. ریدایرکت به درگاه پرداخت (GatewayAmount)
6. بعد از بازگشت موفق از درگاه:
- Verify payment از درگاه
- کسر ActualDiscountAmount از DiscountBalance
- ثبت سفارش با دو مبلغ جدا
- ارسال اطلاعیه به کاربر
```
### مزایا:
✅ کاربر نمی‌تواند کل محصول را با تخفیف بخرد (محدودیت درصد)
✅ کاربر می‌تواند از موجودی تخفیف خود استفاده کند
✅ فروشنده مطمئن است مبلغی واقعی دریافت می‌کند
✅ سیستم از سوء‌استفاده جلوگیری می‌کند
---
## 🔄 تفاوت با Regular Shop
| ویژگی | فروشگاه عادی (Regular) | فروشگاه تخفیفی (Club Discount) |
|-------|------------------------|---------------------------|
| **نوع کیف پول** | `UserWallet.Balance` | `UserWallet.DiscountBalance` + درگاه |
| **نحوه پرداخت** | 100% از Balance یا IPG | **ترکیبی**: X% از DiscountBalance + مابقی از IPG |
| **محدودیت تخفیف** | ندارد | **دارد** (MaxDiscountPercent per product) |
| **نحوه شارژ** | خرید پکیج طلایی (56M) | کمیسیون برداشت Diamond |
| **ارتباط با باشگاه** | ✅ دارد | ✅ دارد (اعضای باشگاه) |
| **محصولات** | `Products` | `DiscountProduct` (یا flag در Products) |
| **سفارش** | `UserOrder` | `DiscountOrder` (با دو مبلغ جدا) |
| **پرداخت** | یک مرحله‌ای | **دو مرحله‌ای**: 1) Verify IPG، 2) Deduct DiscountBalance |
| **TransactionType** | `DepositIpg` | `DiscountPurchase` (hybrid) |
---
## 🏗️ معماری جداسازی
### اصل طراحی:
> **"همه چیز جدا، جز درگاه پرداخت و کیف پول"**
```
┌─────────────────────────────────────────────────────────────────┐
│ User │
│ - Id │
│ - FirstName, LastName, Mobile │
│ - PackagePurchaseMethod │
└────────────┬────────────────────────────────────────────────────┘
├──────────────────────────────────────────┐
│ │
▼ ▼
┌────────────────────────────┐ ┌──────────────────────────┐
│ UserWallet │ │ Transactions (مشترک) │
│ - Balance │ │ - Type │
│ - DiscountBalance │ │ - RefId │
│ - NetworkBalance │ │ - Amount │
└────────────┬───────────────┘ └──────────────────────────┘
├──────────────────────────────────────────┐
│ │
▼ ▼
┌────────────────────────────┐ ┌──────────────────────────┐
│ Regular Shop │ │ Discount Shop │
│ - Products │ │ - DiscountProduct │
│ - Category │ │ - DiscountCategory │
│ - UserCarts │ │ - DiscountShoppingCart │
│ - UserOrder │ │ - DiscountOrder │
│ - FactorDetails │ │ - DiscountOrderDetail │
└────────────────────────────┘ └──────────────────────────┘
```
---
## 🗄️ Entity Design
### 1️⃣ `DiscountProduct`
```csharp
namespace CMSMicroservice.Domain.Entities.DiscountShop;
/// <summary>
/// محصول فروشگاه تخفیفی
/// </summary>
public class DiscountProduct : BaseAuditableEntity
{
/// <summary>
/// عنوان محصول
/// </summary>
public string Title { get; set; }
/// <summary>
/// توضیحات مختصر
/// </summary>
public string ShortInfomation { get; set; }
/// <summary>
/// توضیحات کامل
/// </summary>
public string FullInformation { get; set; }
/// <summary>
/// قیمت (ریال)
/// </summary>
public long Price { get; set; }
/// <summary>
/// درصد تخفیف
/// </summary>
public int DiscountPercent { get; set; }
/// <summary>
/// امتیاز (0 تا 5)
/// </summary>
public int Rate { get; set; }
/// <summary>
/// آدرس تصویر اصلی
/// </summary>
public string ImagePath { get; set; }
/// <summary>
/// آدرس تصویر کوچک
/// </summary>
public string ThumbnailPath { get; set; }
/// <summary>
/// تعداد فروش
/// </summary>
public int SaleCount { get; set; }
/// <summary>
/// تعداد بازدید
/// </summary>
public int ViewCount { get; set; }
/// <summary>
/// موجودی انبار
/// </summary>
public int RemainingCount { get; set; }
/// <summary>
/// وضعیت فعال/غیرفعال
/// </summary>
public bool IsActive { get; set; }
// Navigation Properties
public virtual ICollection<DiscountShoppingCart> ShoppingCarts { get; set; }
public virtual ICollection<DiscountOrderDetail> OrderDetails { get; set; }
public virtual ICollection<DiscountProductCategory> ProductCategories { get; set; }
}
```
---
### 2️⃣ `DiscountCategory`
```csharp
namespace CMSMicroservice.Domain.Entities.DiscountShop;
/// <summary>
/// دسته‌بندی فروشگاه تخفیفی
/// </summary>
public class DiscountCategory : BaseAuditableEntity
{
/// <summary>
/// نام لاتین (برای URL)
/// </summary>
public string Name { get; set; }
/// <summary>
/// عنوان فارسی
/// </summary>
public string Title { get; set; }
/// <summary>
/// توضیحات
/// </summary>
public string? Description { get; set; }
/// <summary>
/// آدرس تصویر
/// </summary>
public string? ImagePath { get; set; }
/// <summary>
/// شناسه والد (برای دسته‌بندی چند سطحی)
/// </summary>
public long? ParentId { get; set; }
/// <summary>
/// Parent Navigation Property
/// </summary>
public virtual DiscountCategory? Parent { get; set; }
/// <summary>
/// فعال/غیرفعال
/// </summary>
public bool IsActive { get; set; }
/// <summary>
/// ترتیب نمایش
/// </summary>
public int SortOrder { get; set; }
// Navigation Properties
public virtual ICollection<DiscountCategory> Children { get; set; }
public virtual ICollection<DiscountProductCategory> ProductCategories { get; set; }
}
```
---
### 3️⃣ `DiscountProductCategory` (Many-to-Many)
```csharp
namespace CMSMicroservice.Domain.Entities.DiscountShop;
/// <summary>
/// رابطه محصول و دسته‌بندی در فروشگاه تخفیفی
/// </summary>
public class DiscountProductCategory : BaseAuditableEntity
{
public long DiscountProductId { get; set; }
public virtual DiscountProduct DiscountProduct { get; set; }
public long DiscountCategoryId { get; set; }
public virtual DiscountCategory DiscountCategory { get; set; }
}
```
---
### 4️⃣ `DiscountShoppingCart`
```csharp
namespace CMSMicroservice.Domain.Entities.DiscountShop;
/// <summary>
/// سبد خرید فروشگاه تخفیفی
/// </summary>
public class DiscountShoppingCart : BaseAuditableEntity
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; set; }
/// <summary>
/// User Navigation Property
/// </summary>
public virtual User User { get; set; }
/// <summary>
/// شناسه محصول
/// </summary>
public long DiscountProductId { get; set; }
/// <summary>
/// DiscountProduct Navigation Property
/// </summary>
public virtual DiscountProduct DiscountProduct { get; set; }
/// <summary>
/// تعداد
/// </summary>
public int Count { get; set; }
/// <summary>
/// قیمت واحد در زمان افزودن به سبد
/// </summary>
public long UnitPrice { get; set; }
}
```
---
### 5️⃣ `DiscountOrder`
```csharp
namespace CMSMicroservice.Domain.Entities.DiscountShop;
/// <summary>
/// سفارش از فروشگاه تخفیفی
/// </summary>
public class DiscountOrder : BaseAuditableEntity
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; set; }
/// <summary>
/// User Navigation Property
/// </summary>
public virtual User User { get; set; }
/// <summary>
/// مبلغ کل سفارش
/// </summary>
public long TotalAmount { get; set; }
/// <summary>
/// مبلغ تخفیف
/// </summary>
public long DiscountAmount { get; set; }
/// <summary>
/// مبلغ قابل پرداخت
/// </summary>
public long PayableAmount { get; set; }
/// <summary>
/// وضعیت پرداخت
/// </summary>
public PaymentStatus PaymentStatus { get; set; }
/// <summary>
/// تاریخ پرداخت
/// </summary>
public DateTime? PaymentDate { get; set; }
/// <summary>
/// شناسه تراکنش (اگر پرداخت موفق باشد)
/// </summary>
public long? TransactionId { get; set; }
/// <summary>
/// Transaction Navigation Property
/// </summary>
public virtual Transactions? Transaction { get; set; }
/// <summary>
/// شناسه آدرس کاربر
/// </summary>
public long UserAddressId { get; set; }
/// <summary>
/// UserAddress Navigation Property
/// </summary>
public virtual UserAddress UserAddress { get; set; }
/// <summary>
/// وضعیت ارسال
/// </summary>
public DeliveryStatus DeliveryStatus { get; set; }
/// <summary>
/// کد رهگیری مرسوله
/// </summary>
public string? TrackingCode { get; set; }
/// <summary>
/// توضیحات وضعیت ارسال
/// </summary>
public string? DeliveryDescription { get; set; }
// Navigation Properties
public virtual ICollection<DiscountOrderDetail> OrderDetails { get; set; }
}
```
---
### 6️⃣ `DiscountOrderDetail`
```csharp
namespace CMSMicroservice.Domain.Entities.DiscountShop;
/// <summary>
/// جزئیات سفارش از فروشگاه تخفیفی
/// </summary>
public class DiscountOrderDetail : BaseAuditableEntity
{
/// <summary>
/// شناسه سفارش
/// </summary>
public long DiscountOrderId { get; set; }
/// <summary>
/// DiscountOrder Navigation Property
/// </summary>
public virtual DiscountOrder DiscountOrder { get; set; }
/// <summary>
/// شناسه محصول
/// </summary>
public long DiscountProductId { get; set; }
/// <summary>
/// DiscountProduct Navigation Property
/// </summary>
public virtual DiscountProduct DiscountProduct { get; set; }
/// <summary>
/// تعداد
/// </summary>
public int Quantity { get; set; }
/// <summary>
/// قیمت واحد در زمان ثبت سفارش
/// </summary>
public long UnitPrice { get; set; }
/// <summary>
/// درصد تخفیف در زمان ثبت سفارش
/// </summary>
public int DiscountPercent { get; set; }
/// <summary>
/// مبلغ کل این آیتم (بعد از تخفیف)
/// </summary>
public long TotalPrice { get; set; }
}
```
---
## 📐 Business Rules
### قانون 1: خرید از Discount Shop فقط با DiscountBalance
```csharp
// در زمان Checkout از Discount Shop:
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId);
if (wallet.DiscountBalance < order.PayableAmount)
{
throw new ValidationException(
$"موجودی کیف پول تخفیفی شما کافی نیست. " +
$"موجودی فعلی: {wallet.DiscountBalance:N0} تومان، " +
$"مبلغ مورد نیاز: {order.PayableAmount:N0} تومان"
);
}
```
---
### قانون 2: خرید از Regular Shop فقط با Balance
```csharp
// در زمان Checkout از Regular Shop:
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId);
if (wallet.Balance < order.Amount)
{
throw new ValidationException(
$"موجودی کیف پول اصلی شما کافی نیست. " +
$"موجودی فعلی: {wallet.Balance:N0} تومان، " +
$"مبلغ مورد نیاز: {order.Amount:N0} تومان"
);
}
```
---
### قانون 3: شارژ DiscountBalance از طریق درگاه
```csharp
// در VerifyDiscountWalletChargeCommand:
wallet.DiscountBalance += amount;
var transaction = new Transactions
{
Type = TransactionType.DiscountWalletCharge,
Amount = amount,
RefId = verifyResult.RefId
};
```
---
### قانون 4: محصولات Discount Shop جدا از Regular Shop
- یک محصول **نمی‌تواند** هم در `Products` باشد، هم در `DiscountProduct`
- Admin باید محصولات را جداگانه مدیریت کند
- هیچ رابطه‌ای بین `Products` و `DiscountProduct` نیست
---
## 🔄 Flow Diagram: خرید از Discount Shop
```
کاربر → مشاهده محصولات Discount Shop
افزودن به DiscountShoppingCart
Checkout (بررسی DiscountBalance)
ثبت DiscountOrder (PaymentStatus: Pending)
کم کردن DiscountBalance از کیف پول
ثبت Transaction (Type: Buy) ← این تراکنش برای خرید است
ثبت DiscountOrderDetail برای هر محصول
به‌روزرسانی DiscountOrder (PaymentStatus: Success)
خالی کردن DiscountShoppingCart
نمایش پیام موفقیت + کد رهگیری
```
**نکته:** در این فلو از درگاه استفاده **نمی‌شود** چون موجودی از قبل شارژ شده است.
---
## 📝 تسک‌های پیاده‌سازی
### Phase 1: Entity Creation (2 روز)
1. **ایجاد namespace جدید**:
- `CMSMicroservice.Domain/Entities/DiscountShop/`
2. **ایجاد Entity‌ها**:
- `DiscountProduct`
- `DiscountCategory`
- `DiscountProductCategory`
- `DiscountShoppingCart`
- `DiscountOrder`
- `DiscountOrderDetail`
3. **ایجاد Configuration‌ها**:
- `DiscountProductConfiguration`
- `DiscountCategoryConfiguration`
- و غیره...
4. **به‌روزرسانی `DbContext`**:
```csharp
public DbSet<DiscountProduct> DiscountProducts { get; set; }
public DbSet<DiscountCategory> DiscountCategories { get; set; }
// ...
```
5. **ایجاد Migration**:
```bash
dotnet ef migrations add AddDiscountShopTables
```
---
### Phase 2: Commands & Queries (3 روز)
#### DiscountProduct CRUD:
- `CreateDiscountProductCommand`
- `UpdateDiscountProductCommand`
- `DeleteDiscountProductCommand`
- `GetDiscountProductByIdQuery`
- `GetDiscountProductsListQuery`
#### DiscountCategory CRUD:
- `CreateDiscountCategoryCommand`
- `UpdateDiscountCategoryCommand`
- `DeleteDiscountCategoryCommand`
- `GetDiscountCategoriesTreeQuery`
#### Shopping Cart:
- `AddToDiscountCartCommand`
- `RemoveFromDiscountCartCommand`
- `GetDiscountCartQuery`
#### Order:
- `CreateDiscountOrderCommand` (Checkout)
- `GetDiscountOrderByIdQuery`
- `GetMyDiscountOrdersQuery` (برای کاربر)
- `UpdateDiscountOrderDeliveryCommand` (برای Admin)
---
### Phase 3: BackOffice.BFF APIs (1 روز)
**Proto file**: `DiscountShopContract.proto`
```protobuf
service DiscountShopContract {
// Product
rpc CreateDiscountProduct(CreateDiscountProductRequest) returns (CreateDiscountProductResponse);
rpc UpdateDiscountProduct(UpdateDiscountProductRequest) returns (UpdateDiscountProductResponse);
rpc GetDiscountProducts(GetDiscountProductsRequest) returns (GetDiscountProductsResponse);
// Category
rpc CreateDiscountCategory(CreateDiscountCategoryRequest) returns (CreateDiscountCategoryResponse);
rpc GetDiscountCategoriesTree(Empty) returns (GetDiscountCategoriesTreeResponse);
// Orders
rpc GetDiscountOrders(GetDiscountOrdersRequest) returns (GetDiscountOrdersResponse);
rpc UpdateDiscountOrderDelivery(UpdateDiscountOrderDeliveryRequest) returns (UpdateDiscountOrderDeliveryResponse);
}
```
---
### Phase 4: FrontOffice.BFF APIs (1 روز)
**Proto file**: `DiscountShopContract.proto` (در FrontOffice.BFF)
```protobuf
service DiscountShopContract {
// Browse
rpc GetDiscountProducts(GetDiscountProductsRequest) returns (GetDiscountProductsResponse);
rpc GetDiscountProductById(GetDiscountProductByIdRequest) returns (GetDiscountProductByIdResponse);
// Cart
rpc AddToDiscountCart(AddToDiscountCartRequest) returns (AddToDiscountCartResponse);
rpc GetMyDiscountCart(Empty) returns (GetMyDiscountCartResponse);
rpc RemoveFromDiscountCart(RemoveFromDiscountCartRequest) returns (RemoveFromDiscountCartResponse);
// Order
rpc CheckoutDiscountCart(CheckoutDiscountCartRequest) returns (CheckoutDiscountCartResponse);
rpc GetMyDiscountOrders(Empty) returns (GetMyDiscountOrdersResponse);
}
```
---
### Phase 5: BackOffice UI (3 روز)
**صفحات مدیریت:**
1. **لیست محصولات تخفیفی** + CRUD
2. **دسته‌بندی‌ها** (Tree View) + CRUD
3. **سفارشات تخفیفی** + تغییر وضعیت ارسال
4. **گزارش فروش** Discount Shop
---
### Phase 6: FrontOffice UI (3 روز)
**صفحات کاربر:**
1. **لیست محصولات تخفیفی** (با فیلتر دسته‌بندی)
2. **جزئیات محصول تخفیفی**
3. **سبد خرید تخفیفی**
4. **Checkout** (با نمایش `DiscountBalance`)
5. **لیست سفارشات تخفیفی کاربر**
---
### Phase 7: Unit Tests (2 روز)
1. تست **CRUD محصولات تخفیفی**
2. تست **AddToDiscountCart**
3. تست **CheckoutDiscountCart**:
- کاربر با موجودی کافی → موفق
- کاربر با موجودی ناکافی → خطا
---
### Phase 8: Documentation (0.5 روز)
- به‌روزرسانی `implementation-progress.md`
- لینک از `REMAINING-TASKS-CONSOLIDATED.md`
---
## 📊 خلاصه Timeline
| Phase | عنوان | زمان |
|-------|-------|------|
| 1 | Entity Creation | 2 روز |
| 2 | Commands & Queries (CMS) | 3 روز |
| 3 | BackOffice.BFF APIs | 1 روز |
| 4 | FrontOffice.BFF APIs | 1 روز |
| 5 | BackOffice UI | 3 روز |
| 6 | FrontOffice UI | 3 روز |
| 7 | Unit Tests | 2 روز |
| 8 | Documentation | 0.5 روز |
| **جمع** | | **15.5 روز** (~3 هفته) |
---
## 🔗 مراجع
- [Package Purchase System](./package-purchase-system.md)
- [Manual Payment System](./manual-payment-system.md)
- [Implementation Progress](./implementation-progress.md)
- [REMAINING-TASKS](../REMAINING-TASKS-CONSOLIDATED.md)
---
**تاریخ آخرین به‌روزرسانی:** 2024-12-02
**نویسنده:** GitHub Copilot
**وضعیت:** ✅ تایید شده توسط کاربر
+548
View File
@@ -0,0 +1,548 @@
# Manual Payment System (سیستم پرداخت دستی مشتریان)
## 📌 Overview
سیستم پرداخت دستی برای مشتریانی که **بدون خرید وام دایا** می‌خواهند مستقیماً 56 میلیون تومان پرداخت کنند و همان مزایا را دریافت کنند.
### 🎯 سناریوها
#### سناریو 1: پرداخت آنلاین (درگاه پرداخت)
```
کاربر → انتخاب گزینه "پرداخت دستی" در فرانت‌آفیس
ایجاد Transaction با Type=ManualPaymentOnline, Amount=56M, Status=Pending
ریدایرکت به درگاه پرداخت (Zarinpal/Mellat/...)
Callback از درگاه با RefId
VerifyManualPaymentCommand → تایید تراکنش
شارژ کیف‌پول‌ها (Balance=56M, NetworkBalance=56M, DiscountBalance=56M)
فعال‌سازی عضویت باشگاه (ClubMembership)
```
#### سناریو 2: کارت‌به‌کارت با تایید ادمین
```
کاربر → کارت‌به‌کارت 56 میلیون + ارسال تصویر رسید
CreateManualPaymentRequestCommand → ثبت درخواست با Status=PendingAdminApproval
- تصویر رسید + کد پیگیری استخراج شده توسط کاربر
ادمین → بررسی درخواست در BackOffice
ApproveManualPaymentCommand یا RejectManualPaymentCommand
در صورت تایید:
- ایجاد Transaction با RefId=کد پیگیری
- شارژ کیف‌پول‌ها
- فعال‌سازی عضویت باشگاه
در صورت رد:
- ثبت دلیل رد
- اطلاع‌رسانی به کاربر
```
---
## 🗂️ Architecture
### Domain Layer
> **وضعیت فعلی پیاده‌سازی (CMS)**
> در نسخه‌ای که الآن در CMS داریم، سناریوی «درخواست پرداخت دستی توسط کاربر» (ManualPaymentRequest + Verify از درگاه) هنوز پیاده‌سازی نشده و فقط بخش **پرداخت دستی توسط Admin/SuperAdmin** با Entity ساده‌تر `ManualPayment` و Enumهای `ManualPaymentType` و `ManualPaymentStatus` (Pending/Approved/Rejected/Cancelled) اجرا شده است.
> بخش‌های زیر که با `ManualPaymentRequest`، `ManualPaymentMethod` و Verify/ProcessManualPayment توضیح داده شده‌اند، طراحی کامل سیستم هستند و برای فاز بعدی (FrontOffice + OnlineGateway/CardToCard) استفاده خواهند شد.
#### **ManualPaymentStatus Enum (طراحی کامل برای Requestها)**
```csharp
public enum ManualPaymentStatus
{
PendingAdminApproval = 0, // در انتظار تایید ادمین (کارت‌به‌کارت)
PendingPayment = 1, // در انتظار پرداخت (درگاه آنلاین)
PaymentVerified = 2, // پرداخت تایید شده (از درگاه)
AdminApproved = 3, // تایید شده توسط ادمین
AdminRejected = 4, // رد شده توسط ادمین
Completed = 5, // تکمیل شده (کیف‌پول شارژ شده)
Failed = 6 // خطا در پردازش
}
```
#### **ManualPaymentMethod Enum**
```csharp
public enum ManualPaymentMethod
{
OnlineGateway = 0, // درگاه آنلاین
CardToCard = 1 // کارت‌به‌کارت
}
```
#### **ManualPaymentRequest Entity (طراحی کامل – هنوز پیاده نشده)**
```csharp
public class ManualPaymentRequest : BaseAuditableEntity
{
public long UserId { get; set; }
public ManualPaymentMethod Method { get; set; }
public ManualPaymentStatus Status { get; set; }
public long Amount { get; set; } = 56_000_000; // مبلغ ثابت
// آنلاین Gateway
public string? GatewayName { get; set; } // Zarinpal, Mellat, etc.
public string? GatewayTrackingCode { get; set; } // کد پیگیری درگاه
public DateTime? GatewayPaymentDate { get; set; }
// کارت‌به‌کارت
public string? ReceiptImageUrl { get; set; } // مسیر تصویر رسید
public string? UserProvidedTrackingCode { get; set; } // کد پیگیری که کاربر داده
public DateTime? CardToCardDate { get; set; }
// تایید/رد ادمین
public long? ApprovedByAdminId { get; set; }
public DateTime? AdminDecisionDate { get; set; }
public string? AdminNotes { get; set; } // توضیحات ادمین (دلیل رد)
// تراکنش نهایی
public long? TransactionId { get; set; }
public bool IsProcessed { get; set; }
public DateTime? ProcessedDate { get; set; }
// Navigation Properties
public virtual User User { get; set; }
public virtual User? ApprovedByAdmin { get; set; }
public virtual Transactions? Transaction { get; set; }
}
```
---
### Application Layer
#### **Commands**
##### 1. CreateManualPaymentRequestCommand (FrontOffice)
ایجاد درخواست پرداخت دستی توسط کاربر
**Request:**
```csharp
public record CreateManualPaymentRequestCommand : IRequest<CreateManualPaymentRequestResponseDto>
{
public long UserId { get; init; }
public ManualPaymentMethod Method { get; init; }
// برای OnlineGateway
public string? GatewayName { get; init; }
public string? ReturnUrl { get; init; } // URL بازگشت بعد از پرداخت
// برای CardToCard
public IFormFile? ReceiptImage { get; init; } // فایل تصویر رسید
public string? TrackingCode { get; init; } // کد پیگیری
public DateTime? TransactionDate { get; init; }
}
```
**Response:**
```csharp
public class CreateManualPaymentRequestResponseDto
{
public long RequestId { get; set; }
public ManualPaymentStatus Status { get; set; }
// برای OnlineGateway: URL پرداخت
public string? PaymentUrl { get; set; }
// برای CardToCard: پیام موفقیت
public string Message { get; set; }
}
```
**Business Logic:**
1. بررسی اینکه کاربر قبلاً درخواست Pending ندارد
2. اگر Method=OnlineGateway:
- ایجاد ManualPaymentRequest با Status=PendingPayment
- فراخوانی Gateway Service برای دریافت URL پرداخت
- ذخیره GatewayName و کد درخواست
- برگرداندن PaymentUrl به کاربر
3. اگر Method=CardToCard:
- آپلود تصویر رسید به Storage
- ایجاد ManualPaymentRequest با Status=PendingAdminApproval
- ذخیره UserProvidedTrackingCode و CardToCardDate
- ارسال نوتیفیکیشن به ادمین‌ها
##### 2. VerifyManualPaymentCommand (Callback از درگاه)
تایید پرداخت آنلاین بعد از بازگشت از درگاه
**Request:**
```csharp
public record VerifyManualPaymentCommand : IRequest<VerifyManualPaymentResponseDto>
{
public long RequestId { get; init; }
public string GatewayTrackingCode { get; init; }
public string? Authority { get; init; } // پارامتر درگاه
}
```
**Business Logic:**
1. یافتن ManualPaymentRequest با Status=PendingPayment
2. فراخوانی Gateway Service برای Verify کردن تراکنش
3. اگر تایید شد:
- به‌روزرسانی Status → PaymentVerified
- ذخیره GatewayTrackingCode و GatewayPaymentDate
- فراخوانی ProcessManualPaymentCommand برای شارژ کیف‌پول
4. اگر رد شد:
- به‌روزرسانی Status → Failed
##### 3. ApproveManualPaymentCommand (Admin)
تایید درخواست کارت‌به‌کارت توسط ادمین
**Request:**
```csharp
public record ApproveManualPaymentCommand : IRequest<ApproveManualPaymentResponseDto>
{
public long RequestId { get; init; }
public long AdminUserId { get; init; }
public string? AdminNotes { get; init; }
}
```
**Business Logic:**
1. بررسی RequestId موجود با Status=PendingAdminApproval
2. بررسی دسترسی ادمین
3. به‌روزرسانی:
- Status → AdminApproved
- ApprovedByAdminId, AdminDecisionDate, AdminNotes
4. فراخوانی ProcessManualPaymentCommand برای شارژ کیف‌پول
##### 4. RejectManualPaymentCommand (Admin)
رد درخواست کارت‌به‌کارت توسط ادمین
**Request:**
```csharp
public record RejectManualPaymentCommand : IRequest<RejectManualPaymentResponseDto>
{
public long RequestId { get; init; }
public long AdminUserId { get; init; }
public string RejectionReason { get; init; } // الزامی
}
```
**Business Logic:**
1. بررسی RequestId موجود
2. به‌روزرسانی:
- Status → AdminRejected
- ApprovedByAdminId, AdminDecisionDate
- AdminNotes = RejectionReason
3. ارسال نوتیفیکیشن به کاربر با دلیل رد
##### 5. ProcessManualPaymentCommand (Internal)
شارژ کیف‌پول‌ها بعد از تایید پرداخت
**این Command داخلی است و فقط توسط Verify یا Approve فراخوانی می‌شود.**
**Business Logic:**
1. ایجاد Transaction:
- Type: DepositManual
- Amount: 56M
- RefId: GatewayTrackingCode یا UserProvidedTrackingCode
2. شارژ Balance: +56M
3. شارژ NetworkBalance: +56M
4. شارژ DiscountBalance: +56M
5. فعال‌سازی ClubMembership (اگر غیرفعال باشد)
6. ثبت UserWalletChangeLog
7. به‌روزرسانی ManualPaymentRequest:
- Status → Completed
- TransactionId, IsProcessed=true, ProcessedDate
8. ارسال نوتیفیکیشن موفقیت به کاربر
##### 6. GetUserManualPaymentHistoryQuery
دریافت تاریخچه پرداخت‌های دستی کاربر
**Request:**
```csharp
public record GetUserManualPaymentHistoryQuery : IRequest<List<ManualPaymentHistoryDto>>
{
public long UserId { get; init; }
}
```
##### 7. GetPendingManualPaymentsQuery (Admin)
دریافت لیست درخواست‌های در انتظار تایید
**Request:**
```csharp
public record GetPendingManualPaymentsQuery : IRequest<List<PendingManualPaymentDto>>
{
public ManualPaymentStatus? StatusFilter { get; init; } = ManualPaymentStatus.PendingAdminApproval;
public int PageNumber { get; init; } = 1;
public int PageSize { get; init; } = 20;
}
```
---
## 💾 Database Schema
### ManualPaymentRequests Table
```sql
CREATE TABLE [CMS].[ManualPaymentRequests] (
[Id] bigint IDENTITY(1,1) PRIMARY KEY,
[UserId] bigint NOT NULL FOREIGN KEY REFERENCES Users(Id),
[Method] int NOT NULL,
[Status] int NOT NULL,
[Amount] bigint NOT NULL DEFAULT 56000000,
-- آنلاین Gateway
[GatewayName] nvarchar(50) NULL,
[GatewayTrackingCode] nvarchar(200) NULL,
[GatewayPaymentDate] datetime2 NULL,
-- کارت‌به‌کارت
[ReceiptImageUrl] nvarchar(500) NULL,
[UserProvidedTrackingCode] nvarchar(200) NULL,
[CardToCardDate] datetime2 NULL,
-- تایید ادمین
[ApprovedByAdminId] bigint NULL FOREIGN KEY REFERENCES Users(Id),
[AdminDecisionDate] datetime2 NULL,
[AdminNotes] nvarchar(max) NULL,
-- تراکنش
[TransactionId] bigint NULL FOREIGN KEY REFERENCES Transactionss(Id),
[IsProcessed] bit NOT NULL DEFAULT 0,
[ProcessedDate] datetime2 NULL,
-- Audit
[Created] datetime2 NOT NULL,
[CreatedBy] nvarchar(max) NULL,
[LastModified] datetime2 NULL,
[LastModifiedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL DEFAULT 0
);
CREATE INDEX IX_ManualPaymentRequests_UserId ON ManualPaymentRequests(UserId);
CREATE INDEX IX_ManualPaymentRequests_Status ON ManualPaymentRequests(Status);
CREATE INDEX IX_ManualPaymentRequests_TransactionId ON ManualPaymentRequests(TransactionId);
```
---
## 🔄 Process Flows
### Flow 1: پرداخت آنلاین
```mermaid
sequenceDiagram
participant User as کاربر
participant FrontOffice as FrontOffice
participant CMS as CMS API
participant Gateway as درگاه پرداخت
User->>FrontOffice: انتخاب "پرداخت دستی"
FrontOffice->>CMS: CreateManualPaymentRequest (Method=OnlineGateway)
CMS->>Gateway: ایجاد درخواست پرداخت
Gateway-->>CMS: PaymentUrl
CMS-->>FrontOffice: PaymentUrl
FrontOffice->>Gateway: ریدایرکت کاربر
User->>Gateway: پرداخت 56M
Gateway->>CMS: Callback (RefId, Authority)
CMS->>Gateway: Verify Payment
Gateway-->>CMS: تایید پرداخت
CMS->>CMS: ProcessManualPayment (شارژ کیف‌پول)
CMS-->>FrontOffice: موفقیت
FrontOffice-->>User: پرداخت موفق
```
### Flow 2: کارت‌به‌کارت
```mermaid
sequenceDiagram
participant User as کاربر
participant FrontOffice as FrontOffice
participant CMS as CMS API
participant Admin as ادمین (BackOffice)
User->>User: کارت‌به‌کارت 56M
User->>FrontOffice: آپلود رسید + کد پیگیری
FrontOffice->>CMS: CreateManualPaymentRequest (Method=CardToCard)
CMS->>CMS: ذخیره تصویر + Status=PendingAdminApproval
CMS-->>Admin: نوتیفیکیشن (درخواست جدید)
Admin->>CMS: GetPendingManualPayments
CMS-->>Admin: لیست درخواست‌ها
Admin->>Admin: بررسی رسید و کد پیگیری
alt تایید
Admin->>CMS: ApproveManualPayment
CMS->>CMS: ProcessManualPayment (شارژ کیف‌پول)
CMS-->>User: نوتیفیکیشن موفقیت
else رد
Admin->>CMS: RejectManualPayment (دلیل رد)
CMS-->>User: نوتیفیکیشن رد با دلیل
end
```
---
## 🧪 Testing Scenarios
### Test 1: پرداخت آنلاین موفق
```bash
# Step 1: ایجاد درخواست
POST /api/manualpayment/create
{
"userId": 123,
"method": 0,
"gatewayName": "Zarinpal",
"returnUrl": "https://example.com/callback"
}
# Response: PaymentUrl
# Step 2: کاربر پرداخت می‌کند (Mock Gateway)
# Step 3: Callback
POST /api/manualpayment/verify
{
"requestId": 456,
"gatewayTrackingCode": "ZP-12345",
"authority": "A00000000..."
}
# Result: کیف‌پول شارژ شده، باشگاه فعال
```
### Test 2: کارت‌به‌کارت با تایید ادمین
```bash
# Step 1: ایجاد درخواست کاربر
POST /api/manualpayment/create
{
"userId": 123,
"method": 1,
"receiptImage": <file>,
"trackingCode": "REF-98765",
"transactionDate": "2024-12-01T10:00:00Z"
}
# Step 2: ادمین بررسی می‌کند
GET /api/admin/manualpayment/pending
# Step 3: ادمین تایید می‌کند
POST /api/admin/manualpayment/approve
{
"requestId": 456,
"adminUserId": 1,
"adminNotes": "رسید معتبر است"
}
# Result: کیف‌پول شارژ شده
```
---
## 📋 Implementation Tasks
### CMS Microservice
#### Domain Layer
- [ ] ایجاد `ManualPaymentStatus` enum
- [ ] ایجاد `ManualPaymentMethod` enum
- [ ] ایجاد `ManualPaymentRequest` entity
- [ ] اضافه کردن به `ApplicationDbContext`
#### Application Layer
- [ ] `CreateManualPaymentRequestCommand` + Handler + Validator
- [ ] `VerifyManualPaymentCommand` + Handler
- [ ] `ApproveManualPaymentCommand` + Handler
- [ ] `RejectManualPaymentCommand` + Handler
- [ ] `ProcessManualPaymentCommand` + Handler (Internal)
- [ ] `GetUserManualPaymentHistoryQuery` + Handler
- [ ] `GetPendingManualPaymentsQuery` + Handler
- [ ] Interface: `IPaymentGatewayService`
- [ ] Interface: `IFileStorageService` (برای آپلود تصویر)
#### Infrastructure Layer
- [ ] `ZarinpalGatewayService` : IPaymentGatewayService
- [ ] `LocalFileStorageService` : IFileStorageService
- [ ] Migration: `AddManualPaymentSystem`
#### WebApi Layer (Protobuf/gRPC)
- [ ] Proto definitions: `ManualPayment.proto`
- [ ] gRPC Service: `ManualPaymentService`
### FrontOffice
#### Components
- [ ] `ManualPaymentPage.razor` - صفحه انتخاب روش پرداخت
- [ ] `OnlinePaymentForm.razor` - فرم پرداخت آنلاین
- [ ] `CardToCardForm.razor` - فرم کارت‌به‌کارت (آپلود رسید)
- [ ] `PaymentCallbackPage.razor` - صفحه بازگشت از درگاه
- [ ] `PaymentHistoryPage.razor` - تاریخچه پرداخت‌های کاربر
#### Services
- [ ] `ManualPaymentService.cs` - فراخوانی BFF
### FrontOffice.BFF
#### Application Layer
- [ ] CQRS Handlers برای مپ کردن gRPC به REST
- [ ] DTOs برای API های REST
#### WebApi Layer
- [ ] `ManualPaymentController.cs` - REST endpoints
### BackOffice
#### Components
- [ ] `PendingPaymentsPage.razor` - لیست درخواست‌های در انتظار
- [ ] `PaymentRequestDetailsModal.razor` - جزئیات + نمایش رسید
- [ ] `ApproveRejectButtons.razor` - دکمه‌های تایید/رد
#### Services
- [ ] `ManualPaymentAdminService.cs` - فراخوانی BFF
### BackOffice.BFF
#### Application Layer
- [ ] Admin CQRS Handlers
- [ ] Admin DTOs
#### WebApi Layer
- [ ] `AdminManualPaymentController.cs` - REST endpoints برای ادمین
---
## ⚠️ Important Notes
### 1. Transaction Type
- برای پرداخت دستی از `TransactionType.DepositManual` استفاده شود
- RefId = GatewayTrackingCode (آنلاین) یا UserProvidedTrackingCode (کارت‌به‌کارت)
### 2. Security
- تایید پرداخت درگاه باید با Signature Verification انجام شود
- تصاویر رسید باید با Validation بارگذاری شوند (حجم، فرمت، محتوا)
- فقط ادمین‌ها حق تایید/رد کارت‌به‌کارت دارند
### 3. Idempotency
- نباید کاربر بتواند چند درخواست همزمان Pending داشته باشد
- هر RequestId فقط یک بار قابل Verify است
### 4. Notifications
- SMS/Email به کاربر بعد از:
- ایجاد درخواست کارت‌به‌کارت
- تایید/رد ادمین
- موفقیت پرداخت آنلاین
### 5. File Storage
- تصاویر رسید باید با GUID ذخیره شوند
- مسیر: `/uploads/receipts/{year}/{month}/{guid}.jpg`
- حداکثر حجم: 2MB
- فرمت‌های مجاز: JPG, PNG, PDF
---
## 🔗 Related Documentation
- [daya-loan-integration.md](./daya-loan-integration.md) - سیستم وام دایا
- [network-club-commission-system-v1.1.md](./network-club-commission-system-v1.1.md) - بیزینس کلی
---
**Created:** 2024-12-01
**Status:** ⚠️ Not Implemented Yet (Design Complete)
**Priority:** High (برای کاربران بدون وام دایا ضروری است)
+967
View File
@@ -0,0 +1,967 @@
# Package Purchase System - سیستم خرید پکیج طلایی
**تاریخ ایجاد:** 2024-12-02
**وضعیت:** در حال طراحی
**اولویت:** 🔴 بسیار بالا
---
## 📋 فهرست
1. [مقدمه](#مقدمه)
2. [سه سناریوی اصلی](#سه-سناریوی-اصلی)
3. [Entity Changes](#entity-changes)
4. [Business Rules](#business-rules)
5. [Flow Diagrams](#flow-diagrams)
6. [Commands & Handlers](#commands--handlers)
7. [تسک‌های پیاده‌سازی](#تسک-های-پیاده-سازی)
---
## 🎯 مقدمه
سیستم خرید پکیج طلایی سه سناریوی مختلف دارد که باید به درستی از هم تفکیک شوند:
### هدف کلی:
- **سناریو 1 و 2**: خرید پکیج طلایی (56 میلیون تومان) → امکان فعالسازی باشگاه مشتریان
- **سناریو 3**: شارژ عادی کیف پول تخفیفی → فقط برای خرید از فروشگاه تخفیفی
### نکات کلیدی:
1. کاربر فقط **یک بار** می‌تواند پکیج طلایی خریداری کند (سناریو 1 یا 2)
2. بعد از خرید پکیج، کاربر **باید خودش** دکمه فعالسازی باشگاه را بزند
3. فعالسازی باشگاه **نیاز به تایید Admin ندارد**
4. عضویت در شبکه (NetworkMembership) **جدا** از عضویت در باشگاه (ClubMembership) است
5. کمیسیون‌ها **فقط بعد** از فعالسازی باشگاه محاسبه می‌شوند
---
## 🔄 سه سناریوی اصلی
### 📌 سناریو 1: دریافت وام دایا (DayaLoan)
```
کاربر → درخواست وام از دایا → دایا وام را تایید می‌کند
شارژ Balance در UserWallet (56,000,000 تومان)
ثبت UserWalletChangeLog (Amount: +56M, Type: DepositExternal1)
ثبت Transaction (Type: DepositExternal1, RefId: شماره قرارداد دایا)
ثبت UserOrder (PackageId: پکیج طلایی, TransactionId: xxx, Amount: 56M)
کاربر می‌تواند با این 56M از فروشگاه عادی خرید کند
[کاربر باید خودش دکمه "فعالسازی باشگاه مشتریان" را بزند]
ثبت/به‌روزرسانی ClubMembership (IsActive: true, PurchaseMethod: DayaLoan)
شروع محاسبه کمیسیون‌ها
```
**نکات:**
- `Transaction.Type` = `DepositExternal1` (وام دایا)
- `Transaction.RefId` = شماره قرارداد دایا
- `UserOrder.PackageId` پر می‌شود
- `User.PackagePurchaseMethod` = `DayaLoan`
---
### 📌 سناریو 2: خرید پکیج طلایی از درگاه (Direct Purchase)
```
کاربر → انتخاب پکیج طلایی (56M) → کلیک "پرداخت"
ثبت UserOrder (PackageId: پکیج طلایی, Amount: 56M, PaymentStatus: Pending)
Redirect به درگاه بانکی (IPG)
کاربر پرداخت می‌کند و بر می‌گردد
Verify پرداخت با بانک
شارژ Balance در UserWallet (56,000,000 تومان)
ثبت UserWalletChangeLog (Amount: +56M, Type: DepositIpg)
ثبت Transaction (Type: DepositIpg, RefId: کد پیگیری بانک)
به‌روزرسانی UserOrder (TransactionId: xxx, PaymentStatus: Success)
کاربر می‌تواند با این 56M از فروشگاه عادی خرید کند
[کاربر باید خودش دکمه "فعالسازی باشگاه مشتریان" را بزند]
ثبت/به‌روزرسانی ClubMembership (IsActive: true, PurchaseMethod: DirectPurchase)
شروع محاسبه کمیسیون‌ها
```
**نکات:**
- `Transaction.Type` = `DepositIpg` (پرداخت از درگاه)
- `Transaction.RefId` = کد پیگیری بانک
- `UserOrder.PackageId` پر می‌شود
- `User.PackagePurchaseMethod` = `DirectPurchase`
---
### 📌 سناریو 3: شارژ عادی کیف پول تخفیفی (Regular Wallet Charge)
```
کاربر → انتخاب مبلغ دلخواه → کلیک "شارژ کیف پول"
Redirect به درگاه بانکی (IPG)
کاربر پرداخت می‌کند و بر می‌گردد
Verify پرداخت با بانک
شارژ DiscountBalance در UserWallet (مبلغ دلخواه)
ثبت UserWalletChangeLog (Amount: +xxx, Type: DiscountWalletCharge)
ثبت Transaction (Type: DiscountWalletCharge, RefId: کد پیگیری بانک)
کاربر می‌تواند فقط از فروشگاه تخفیفی خرید کند
[هیچ ارتباطی با باشگاه مشتریان ندارد]
```
**نکات:**
- `Transaction.Type` = `DiscountWalletCharge`
- `Transaction.RefId` = کد پیگیری بانک
- **PackageId در هیچ جا ثبت نمی‌شود**
- فقط `DiscountBalance` شارژ می‌شود، نه `Balance`
- هیچ `UserOrder` با `PackageId` ثبت نمی‌شود
---
## 🗄️ Entity Changes
### 1️⃣ **Enum جدید: `PackagePurchaseMethod`**
```csharp
namespace CMSMicroservice.Domain.Enums;
/// <summary>
/// نحوه خرید پکیج طلایی توسط کاربر
/// </summary>
public enum PackagePurchaseMethod
{
/// <summary>
/// هنوز پکیج خریداری نکرده
/// </summary>
None = 0,
/// <summary>
/// از طریق وام دایا
/// </summary>
DayaLoan = 1,
/// <summary>
/// از طریق پرداخت مستقیم درگاه بانکی
/// </summary>
DirectPurchase = 2
}
```
**محل:** `CMS/src/CMSMicroservice.Domain/Enums/PackagePurchaseMethod.cs`
---
### 2️⃣ **تغییرات `User` Entity**
```csharp
// اضافه کردن این فیلد به User.cs:
/// <summary>
/// نحوه خرید پکیج طلایی (برای جلوگیری از خرید مجدد)
/// </summary>
public PackagePurchaseMethod PackagePurchaseMethod { get; set; } = PackagePurchaseMethod.None;
```
**منطق:**
- وقتی کاربر سناریو 1 یا 2 را انجام می‌دهد، این فیلد تغییر می‌کند
- اگر `PackagePurchaseMethod != None` باشد، کاربر نمی‌تواند دوباره پکیج خریداری کند
---
### 3️⃣ **تغییرات `ClubMembership` Entity**
```csharp
// اضافه کردن این فیلد به ClubMembership.cs:
/// <summary>
/// نحوه خرید پکیج که منجر به فعالسازی باشگاه شد
/// </summary>
public PackagePurchaseMethod PurchaseMethod { get; set; }
```
**منطق:**
- وقتی کاربر دکمه "فعالسازی باشگاه" را می‌زند، این فیلد از `User.PackagePurchaseMethod` کپی می‌شود
- برای گزارش‌گیری و تحلیل: چند نفر از طریق وام دایا و چند نفر از طریق خرید مستقیم عضو شدند
---
### 4️⃣ **تغییرات `TransactionType` Enum**
```csharp
// فعلاً موجود است:
public enum TransactionType
{
Buy = 0,
DepositIpg = 1, // پرداخت از درگاه (سناریو 2)
DepositExternal1 = 2, // وام دایا (سناریو 1)
Withdraw = 3,
NetworkCommission = 10,
ClubActivation = 11,
DiscountWalletCharge = 12 // شارژ کیف پول تخفیفی (سناریو 3) ✅
}
```
**نکته:** `DiscountWalletCharge` از قبل وجود دارد، پس نیازی به تغییر نیست.
---
## 📐 Business Rules
### قانون 1: یک کاربر فقط یک بار می‌تواند پکیج طلایی خریداری کند
```csharp
// Check قبل از خرید پکیج:
if (user.PackagePurchaseMethod != PackagePurchaseMethod.None)
{
throw new ValidationException("شما قبلاً پکیج طلایی را خریداری کرده‌اید.");
}
```
---
### قانون 2: فعالسازی باشگاه فقط با موجودی اصلی (Balance) امکان‌پذیر است
```csharp
// Check موقع فعالسازی باشگاه:
var userWallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId);
if (userWallet.Balance < 56_000_000)
{
throw new ValidationException("برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید.");
}
```
---
### قانون 3: فعالسازی باشگاه فقط برای کسانی که پکیج خریده‌اند
```csharp
// Check موقع فعالسازی باشگاه:
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
throw new ValidationException("برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید.");
}
// پیدا کردن UserOrder مربوط به پکیج:
var packageOrder = await _context.UserOrders
.FirstOrDefaultAsync(o =>
o.UserId == userId &&
o.PackageId != null &&
o.PaymentStatus == PaymentStatus.Success
);
if (packageOrder == null)
{
throw new ValidationException("سفارش پکیج طلایی یافت نشد.");
}
// پیدا کردن Transaction مربوطه:
var transaction = await _context.Transactions
.FirstOrDefaultAsync(t => t.Id == packageOrder.TransactionId);
if (transaction == null ||
(transaction.Type != TransactionType.DepositIpg &&
transaction.Type != TransactionType.DepositExternal1))
{
throw new ValidationException("تراکنش معتبر برای فعالسازی باشگاه یافت نشد.");
}
```
---
### قانون 4: NetworkMembership جدا از ClubMembership است
- **NetworkMembership**: موقع ثبت‌نام کاربر خودکار ایجاد می‌شود (با `ParentId`)
- **ClubMembership**: فقط وقتی کاربر دکمه "فعالسازی باشگاه" را بزند ایجاد می‌شود
- کاربر می‌تواند زیرمجموعه بگیرد بدون اینکه جزو باشگاه باشد (ولی سیاست‌گذاری می‌کنیم که قبل از گرفتن زیرمجموعه باید باشگاه را فعال کرده باشد)
---
### قانون 5: محاسبه کمیسیون فقط بعد از فعالسازی باشگاه
```csharp
// در محاسبه کمیسیون:
var clubMembership = await _context.ClubMemberships
.FirstOrDefaultAsync(c => c.UserId == userId && c.IsActive);
if (clubMembership == null)
{
// این کاربر کمیسیون نمی‌گیرد چون جزو باشگاه نیست
return;
}
// ادامه محاسبه کمیسیون...
```
---
## 📊 Flow Diagrams
### 🔹 Flow 1: خرید پکیج از درگاه (سناریو 2)
```
┌─────────────────────────────────────────────────────────────┐
│ FrontOffice UI (کاربر) │
└──────────────────────┬──────────────────────────────────────┘
┌─────────────────────────┐
│ انتخاب پکیج طلایی (56M) │
└────────────┬─────────────┘
┌──────────────────────────────────────┐
│ PurchaseGoldenPackageCommand │
│ - بررسی User.PackagePurchaseMethod │
│ - ثبت UserOrder (Pending) │
│ - Redirect به درگاه │
└────────────┬─────────────────────────┘
┌──────────────────────────────────────┐
│ درگاه بانکی (IPG) │
│ کاربر پرداخت می‌کند │
└────────────┬─────────────────────────┘
┌──────────────────────────────────────┐
│ VerifyGoldenPackagePurchaseCommand │
│ - Verify با بانک │
│ - شارژ UserWallet.Balance (56M) │
│ - ثبت Transaction (DepositIpg) │
│ - ثبت UserWalletChangeLog │
│ - Set User.PackagePurchaseMethod │
│ = DirectPurchase │
│ - به‌روزرسانی UserOrder (Success) │
└────────────┬─────────────────────────┘
┌──────────────────────────────────────┐
│ کاربر می‌تواند از فروشگاه عادی │
│ خرید کند (با Balance) │
└──────────────────────────────────────┘
```
---
### 🔹 Flow 2: فعالسازی باشگاه مشتریان
```
┌─────────────────────────────────────────────────────────────┐
│ FrontOffice UI (کاربر وارد شده) │
│ کاربر دکمه "فعالسازی باشگاه" را می‌زند │
└──────────────────────┬──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ ActivateClubMembershipCommand │
│ │
│ 1. بررسی User.PackagePurchaseMethod │
│ → باید != None باشد │
│ │
│ 2. بررسی UserWallet.Balance │
│ → باید >= 56M باشد │
│ │
│ 3. پیدا کردن UserOrder با PackageId │
│ → PaymentStatus = Success │
│ │
│ 4. پیدا کردن Transaction │
│ → Type = DepositIpg یا │
│ DepositExternal1 │
│ │
│ 5. ثبت/به‌روزرسانی ClubMembership │
│ - IsActive = true │
│ - ActivatedAt = DateTime.Now │
│ - PurchaseMethod = کپی از User │
│ │
└────────────┬─────────────────────────┘
┌──────────────────────────────────────┐
│ کاربر جزو باشگاه مشتریان شد │
│ کمیسیون‌ها شروع به محاسبه می‌کنند │
└──────────────────────────────────────┘
```
---
### 🔹 Flow 3: شارژ کیف پول تخفیفی (سناریو 3)
```
┌─────────────────────────────────────────────────────────────┐
│ FrontOffice UI (کاربر) │
└──────────────────────┬──────────────────────────────────────┘
┌─────────────────────────┐
│ انتخاب مبلغ دلخواه │
│ (برای فروشگاه تخفیفی) │
└────────────┬─────────────┘
┌──────────────────────────────────────┐
│ ChargeDiscountWalletCommand │
│ - Redirect به درگاه │
└────────────┬─────────────────────────┘
┌──────────────────────────────────────┐
│ درگاه بانکی (IPG) │
│ کاربر پرداخت می‌کند │
└────────────┬─────────────────────────┘
┌──────────────────────────────────────┐
│ VerifyDiscountWalletChargeCommand │
│ - Verify با بانک │
│ - شارژ UserWallet.DiscountBalance │
│ - ثبت Transaction │
│ (Type: DiscountWalletCharge) │
│ - ثبت UserWalletChangeLog │
└────────────┬─────────────────────────┘
┌──────────────────────────────────────┐
│ کاربر می‌تواند از فروشگاه تخفیفی │
│ خرید کند (با DiscountBalance) │
└──────────────────────────────────────┘
```
**نکته:** در این سناریو هیچ `UserOrder` با `PackageId` ثبت نمی‌شود.
---
## 💻 Commands & Handlers
### 1️⃣ `PurchaseGoldenPackageCommand`
**مسئولیت:** ایجاد سفارش پکیج طلایی و Redirect به درگاه
```csharp
public class PurchaseGoldenPackageCommand : IRequest<PaymentInitiateResult>
{
public long UserId { get; set; }
}
public class PurchaseGoldenPackageCommandHandler
: IRequestHandler<PurchaseGoldenPackageCommand, PaymentInitiateResult>
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
public async Task<PaymentInitiateResult> Handle(
PurchaseGoldenPackageCommand request,
CancellationToken cancellationToken)
{
// 1. بررسی User
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
throw new NotFoundException(nameof(User), request.UserId);
// 2. بررسی اینکه قبلاً پکیج نخریده باشد
if (user.PackagePurchaseMethod != PackagePurchaseMethod.None)
{
throw new ValidationException("شما قبلاً پکیج طلایی را خریداری کرده‌اید.");
}
// 3. پیدا کردن پکیج طلایی
var goldenPackage = await _context.Packages
.FirstOrDefaultAsync(p => p.Title.Contains("طلایی"), cancellationToken);
if (goldenPackage == null)
throw new NotFoundException("پکیج طلایی یافت نشد.");
// 4. ایجاد UserOrder
var order = new UserOrder
{
UserId = user.Id,
PackageId = goldenPackage.Id,
Amount = goldenPackage.Price, // 56,000,000
PaymentStatus = PaymentStatus.Pending,
DeliveryStatus = DeliveryStatus.None,
UserAddressId = 0 // پکیج نیاز به آدرس ندارد
};
_context.UserOrders.Add(order);
await _context.SaveChangesAsync(cancellationToken);
// 5. Redirect به درگاه
var paymentRequest = new PaymentRequest
{
Amount = order.Amount,
OrderId = order.Id.ToString(),
CallbackUrl = "https://yourdomain.com/verify-golden-package",
Description = $"خرید پکیج طلایی"
};
var result = await _paymentGateway.InitiatePaymentAsync(paymentRequest);
return result;
}
}
```
---
### 2️⃣ `VerifyGoldenPackagePurchaseCommand`
**مسئولیت:** Verify پرداخت و شارژ کیف پول
```csharp
public class VerifyGoldenPackagePurchaseCommand : IRequest<bool>
{
public long OrderId { get; set; }
public string Authority { get; set; } // از درگاه
}
public class VerifyGoldenPackagePurchaseCommandHandler
: IRequestHandler<VerifyGoldenPackagePurchaseCommand, bool>
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
public async Task<bool> Handle(
VerifyGoldenPackagePurchaseCommand request,
CancellationToken cancellationToken)
{
// 1. پیدا کردن Order
var order = await _context.UserOrders
.Include(o => o.Package)
.Include(o => o.User)
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);
if (order == null)
throw new NotFoundException(nameof(UserOrder), request.OrderId);
// 2. Verify با بانک
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority,
order.Amount
);
if (!verifyResult.IsSuccess)
{
order.PaymentStatus = PaymentStatus.Failed;
await _context.SaveChangesAsync(cancellationToken);
return false;
}
// 3. شارژ کیف پول
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
wallet.Balance += order.Amount; // 56,000,000
// 4. ثبت Transaction
var transaction = new Transactions
{
Amount = order.Amount,
Description = "خرید پکیج طلایی از درگاه",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
RefId = verifyResult.RefId,
Type = TransactionType.DepositIpg
};
_context.Transactions.Add(transaction);
await _context.SaveChangesAsync(cancellationToken);
// 5. ثبت ChangeLog
var changeLog = new UserWalletChangeLog
{
UserId = order.UserId,
Amount = order.Amount,
ChangeType = WalletChangeType.Deposit,
Description = "شارژ موجودی از پکیج طلایی",
BalanceBefore = wallet.Balance - order.Amount,
BalanceAfter = wallet.Balance
};
_context.UserWalletChangeLogs.Add(changeLog);
// 6. به‌روزرسانی Order
order.TransactionId = transaction.Id;
order.PaymentStatus = PaymentStatus.Success;
order.PaymentDate = DateTime.Now;
order.PaymentMethod = PaymentMethod.Online;
// 7. تغییر User.PackagePurchaseMethod
order.User.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}
```
---
### 3️⃣ `ActivateClubMembershipCommand`
**مسئولیت:** فعالسازی عضویت در باشگاه مشتریان
```csharp
public class ActivateClubMembershipCommand : IRequest<bool>
{
public long UserId { get; set; }
}
public class ActivateClubMembershipCommandHandler
: IRequestHandler<ActivateClubMembershipCommand, bool>
{
private readonly IApplicationDbContext _context;
public async Task<bool> Handle(
ActivateClubMembershipCommand request,
CancellationToken cancellationToken)
{
// 1. بررسی User
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
throw new NotFoundException(nameof(User), request.UserId);
// 2. بررسی اینکه پکیج خریده باشد
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
throw new ValidationException(
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید."
);
}
// 3. بررسی موجودی
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
if (wallet.Balance < 56_000_000)
{
throw new ValidationException(
"برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید."
);
}
// 4. بررسی UserOrder
var packageOrder = await _context.UserOrders
.FirstOrDefaultAsync(o =>
o.UserId == user.Id &&
o.PackageId != null &&
o.PaymentStatus == PaymentStatus.Success,
cancellationToken
);
if (packageOrder == null)
{
throw new ValidationException("سفارش پکیج طلایی یافت نشد.");
}
// 5. بررسی Transaction
var transaction = await _context.Transactions
.FirstOrDefaultAsync(t => t.Id == packageOrder.TransactionId, cancellationToken);
if (transaction == null ||
(transaction.Type != TransactionType.DepositIpg &&
transaction.Type != TransactionType.DepositExternal1))
{
throw new ValidationException("تراکنش معتبر برای فعالسازی باشگاه یافت نشد.");
}
// 6. بررسی اینکه قبلاً فعال نکرده باشد
var existingMembership = await _context.ClubMemberships
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
if (existingMembership != null && existingMembership.IsActive)
{
throw new ValidationException("شما قبلاً عضو باشگاه مشتریان هستید.");
}
// 7. ثبت یا به‌روزرسانی ClubMembership
if (existingMembership == null)
{
existingMembership = new ClubMembership
{
UserId = user.Id,
IsActive = true,
ActivatedAt = DateTime.Now,
InitialContribution = 56_000_000,
TotalEarned = 0,
PurchaseMethod = user.PackagePurchaseMethod
};
_context.ClubMemberships.Add(existingMembership);
}
else
{
existingMembership.IsActive = true;
existingMembership.ActivatedAt = DateTime.Now;
existingMembership.PurchaseMethod = user.PackagePurchaseMethod;
}
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}
```
---
### 4️⃣ `ChargeDiscountWalletCommand` (سناریو 3)
**مسئولیت:** شارژ کیف پول تخفیفی
```csharp
public class ChargeDiscountWalletCommand : IRequest<PaymentInitiateResult>
{
public long UserId { get; set; }
public long Amount { get; set; }
}
public class ChargeDiscountWalletCommandHandler
: IRequestHandler<ChargeDiscountWalletCommand, PaymentInitiateResult>
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
public async Task<PaymentInitiateResult> Handle(
ChargeDiscountWalletCommand request,
CancellationToken cancellationToken)
{
// 1. بررسی User
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
throw new NotFoundException(nameof(User), request.UserId);
// 2. بررسی مبلغ (حداقل 10,000 تومان)
if (request.Amount < 10_000)
{
throw new ValidationException("حداقل مبلغ شارژ 10,000 تومان است.");
}
// 3. Redirect به درگاه
var paymentRequest = new PaymentRequest
{
Amount = request.Amount,
OrderId = $"DISCOUNT_{user.Id}_{DateTime.Now:yyyyMMddHHmmss}",
CallbackUrl = "https://yourdomain.com/verify-discount-wallet",
Description = $"شارژ کیف پول تخفیفی"
};
var result = await _paymentGateway.InitiatePaymentAsync(paymentRequest);
return result;
}
}
```
---
### 5️⃣ `VerifyDiscountWalletChargeCommand` (سناریو 3)
**مسئولیت:** Verify و شارژ DiscountBalance
```csharp
public class VerifyDiscountWalletChargeCommand : IRequest<bool>
{
public long UserId { get; set; }
public long Amount { get; set; }
public string Authority { get; set; }
}
public class VerifyDiscountWalletChargeCommandHandler
: IRequestHandler<VerifyDiscountWalletChargeCommand, bool>
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
public async Task<bool> Handle(
VerifyDiscountWalletChargeCommand request,
CancellationToken cancellationToken)
{
// 1. پیدا کردن User
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
throw new NotFoundException(nameof(User), request.UserId);
// 2. Verify با بانک
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority,
request.Amount
);
if (!verifyResult.IsSuccess)
{
return false;
}
// 3. شارژ DiscountBalance
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
wallet.DiscountBalance += request.Amount;
// 4. ثبت Transaction
var transaction = new Transactions
{
Amount = request.Amount,
Description = "شارژ کیف پول تخفیفی",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
RefId = verifyResult.RefId,
Type = TransactionType.DiscountWalletCharge
};
_context.Transactions.Add(transaction);
await _context.SaveChangesAsync(cancellationToken);
// 5. ثبت ChangeLog
var changeLog = new UserWalletChangeLog
{
UserId = user.Id,
Amount = request.Amount,
ChangeType = WalletChangeType.Deposit,
Description = "شارژ موجودی تخفیفی",
BalanceBefore = wallet.DiscountBalance - request.Amount,
BalanceAfter = wallet.DiscountBalance
};
_context.UserWalletChangeLogs.Add(changeLog);
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}
```
---
## 📝 تسک‌های پیاده‌سازی
### Phase 1: Entity Changes (1 روز)
1. **ایجاد `PackagePurchaseMethod` Enum**
- محل: `CMSMicroservice.Domain/Enums/PackagePurchaseMethod.cs`
- مقادیر: None, DayaLoan, DirectPurchase
2. **اضافه کردن فیلد به `User`**
- فیلد: `PackagePurchaseMethod PackagePurchaseMethod`
- مقدار پیش‌فرض: `PackagePurchaseMethod.None`
3. **اضافه کردن فیلد به `ClubMembership`**
- فیلد: `PackagePurchaseMethod PurchaseMethod`
4. **ایجاد Migration**
```bash
dotnet ef migrations add AddPackagePurchaseMethod
```
---
### Phase 2: Commands (2 روز)
1. **`PurchaseGoldenPackageCommand`**
- بررسی `User.PackagePurchaseMethod`
- ثبت `UserOrder` با `PackageId`
- Redirect به درگاه
2. **`VerifyGoldenPackagePurchaseCommand`**
- Verify پرداخت
- شارژ `Balance`
- ثبت `Transaction` (DepositIpg)
- Set `User.PackagePurchaseMethod = DirectPurchase`
3. **`ActivateClubMembershipCommand`**
- چک‌های امنیتی (UserOrder + Transaction)
- ثبت/به‌روزرسانی `ClubMembership`
4. **`ChargeDiscountWalletCommand` + `VerifyDiscountWalletChargeCommand`**
- شارژ `DiscountBalance`
- ثبت `Transaction` (DiscountWalletCharge)
---
### Phase 3: به‌روزرسانی DayaLoan Flow (0.5 روز)
- تغییر `ProcessDayaLoanCommandHandler`:
```csharp
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan;
```
---
### Phase 4: Unit Tests (1 روز)
1. تست `PurchaseGoldenPackageCommand`:
- کاربری که قبلاً پکیج خریده → باید خطا بدهد
- کاربر جدید → باید Order ایجاد شود
2. تست `ActivateClubMembershipCommand`:
- کاربر بدون پکیج → خطا
- کاربر با موجودی کمتر از 56M → خطا
- کاربر معتبر → موفق
3. تست `VerifyDiscountWalletChargeCommand`:
- پرداخت موفق → `DiscountBalance` افزایش یابد
- پرداخت ناموفق → هیچ تغییری نکند
---
### Phase 5: Documentation (0.5 روز)
- به‌روزرسانی `implementation-progress.md`
- لینک از `REMAINING-TASKS-CONSOLIDATED.md`
---
## 📊 خلاصه Timeline
| Phase | عنوان | زمان |
|-------|-------|------|
| 1 | Entity Changes | 1 روز |
| 2 | Commands & Handlers | 2 روز |
| 3 | DayaLoan Flow Update | 0.5 روز |
| 4 | Unit Tests | 1 روز |
| 5 | Documentation | 0.5 روز |
| **جمع** | | **5 روز** |
---
## 🔗 مراجع
- [DayaLoan Integration](./daya-loan-integration.md)
- [Manual Payment System](./manual-payment-system.md)
- [Implementation Progress](./implementation-progress.md)
- [REMAINING-TASKS](../REMAINING-TASKS-CONSOLIDATED.md)
---
**تاریخ آخرین به‌روزرسانی:** 2024-12-02
**نویسنده:** GitHub Copilot
**وضعیت:** ✅ تایید شده توسط کاربر