feat: Complete overhaul of FourSat documentation structure and content

- Added FINAL-STATUS.md detailing project completion and key metrics
- Created QUICK-REFERENCE.md for quick access to essential documents
- Updated README.md with project overview and quick start guide
- Established STRUCTURE.md outlining the final documentation structure
- Organized and archived old files, ensuring a clean and efficient directory
- Enhanced documentation quality with comprehensive metrics and checklists
This commit is contained in:
masoodafar-web
2025-12-04 17:32:31 +03:30
commit 119e870a26
67 changed files with 210873 additions and 0 deletions
+420
View File
@@ -0,0 +1,420 @@
# Balance Calculation with Carryover Logic - Complete Guide
**Date**: 2025-12-01
**Last Updated**: 2025-12-01 (Added Configuration Integration + MaxWeeklyBalances Cap)
**Status**: ✅ Implemented
**Migration**: `UpdateNetworkWeeklyBalanceWithCarryover`
---
## 📋 Configuration-Based Calculation
### **System Configurations Used:**
```csharp
// تمام مقادیر از جدول SystemConfigurations خوانده می‌شوند
Club.ActivationFee = 25,000,000 ریال (هزینه فعالسازی)
Commission.WeeklyPoolContributionPercent = 20% (سهم استخر)
Commission.MaxWeeklyBalancesPerUser = 300 (سقف تعادل هفتگی)
```
### **Pool Contribution Calculation:**
```csharp
totalNewMembers = leftNewMembers + rightNewMembers
weeklyPoolContribution = totalNewMembers × activationFee × poolPercent
= totalNewMembers × 25,000,000 × 20%
= totalNewMembers × 5,000,000
```
**مثال:**
اگر 10 نفر جدید جذب شوند: `10 × 5,000,000 = 50,000,000` ریال به استخر اضافه می‌شود.
---
## 🚫 MaxWeeklyBalances Cap (محدودیت سقف)
### **Logic:**
```csharp
totalBalances = MIN(leftTotal, rightTotal)
cappedBalances = MIN(totalBalances, maxWeeklyBalances) // 300
// اگر بیشتر از سقف بود، مازاد به remainder اضافه می‌شود
excessBalances = totalBalances - cappedBalances
```
### **Example:**
```
Week 5:
leftTotal = 350, rightTotal = 400
totalBalances = MIN(350, 400) = 350
cappedBalances = MIN(350, 300) = 300 ✅ محدود شد!
excessBalances = 350 - 300 = 50
leftRemainder = 0 + 50 = 50 (میرود برای هفته بعد)
rightRemainder = 50
```
---
## 📊 Problem Statement
### ❌ **Previous (Incorrect) Logic:**
```csharp
// محاسبه تعداد کل اعضا در هر پا
leftLegBalances = CountAllMembers(userId, Left);
rightLegBalances = CountAllMembers(userId, Right);
// تعادل = کمترین مقدار
TotalBalances = MIN(leftLegBalances, rightLegBalances);
```
**مشکلات:**
1. تعداد کل اعضا را می‌شمارد (نه فقط جدیدها)
2. باقیمانده هفته قبل را نادیده می‌گیرد
3. هر هفته از صفر شروع می‌کند
---
## ✅ **Current (Correct) Logic:**
### **Formula:**
```
leftTotal = leftNewMembers + leftCarryover
rightTotal = rightNewMembers + rightCarryover
TotalBalances = MIN(leftTotal, rightTotal)
leftRemainder = leftTotal - TotalBalances
rightRemainder = rightTotal - TotalBalances
```
### **Key Principles:**
1. **Only count NEW members** activated in current week
2. **Add carryover** from previous week
3. **Calculate remainder** for next week
4. **Recursive counting** through entire tree structure
---
## 🔢 Example Calculations
### **Week 1 (2025-W48):**
**Tree Structure:**
```
User A (Activated this week - 25M to pool)
├─ Left: User B (Activated this week - 25M)
└─ Right: User C (Activated this week - 25M)
```
**Calculations:**
```
User A:
leftNewMembers = 1 (User B activated)
rightNewMembers = 1 (User C activated)
leftCarryover = 0 (first week)
rightCarryover = 0 (first week)
leftTotal = 1 + 0 = 1
rightTotal = 1 + 0 = 1
TotalBalances = MIN(1, 1) = 1
leftRemainder = 1 - 1 = 0
rightRemainder = 1 - 1 = 0
User B: TotalBalances = 0 (no children)
User C: TotalBalances = 0 (no children)
```
**Pool Calculation:**
```
Total Pool = 75M (3 activations × 25M)
Total Balances = 1 (only User A)
Value Per Balance = 75M ÷ 1 = 75M
Commission:
User A = 1 × 75M = 75M
```
---
### **Week 2 (2025-W49):**
**Tree Structure:**
```
User A
├─ Left: User B
│ ├─ Left: User D (NEW - activated this week - 25M)
│ └─ Right: User E (NEW - activated this week - 25M)
└─ Right: User C
├─ Left: User F (NEW - activated this week - 25M)
└─ Right: User G (NEW - activated this week - 25M)
```
**Calculations:**
```
User B:
leftNewMembers = 1 (User D)
rightNewMembers = 1 (User E)
leftCarryover = 0
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 1 + 0 = 1
TotalBalances = MIN(1, 1) = 1
User C:
leftNewMembers = 1 (User F)
rightNewMembers = 1 (User G)
leftCarryover = 0
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 1 + 0 = 1
TotalBalances = MIN(1, 1) = 1
User A:
leftNewMembers = 2 (D & E through B)
rightNewMembers = 2 (F & G through C)
leftCarryover = 0 (from week 1)
rightCarryover = 0 (from week 1)
leftTotal = 2 + 0 = 2
rightTotal = 2 + 0 = 2
TotalBalances = MIN(2, 2) = 2 ✅
leftRemainder = 2 - 2 = 0
rightRemainder = 2 - 2 = 0
```
**Pool Calculation:**
```
Total Pool = 100M (4 new activations × 25M)
Total Balances = 4 (A=2, B=1, C=1)
Value Per Balance = 100M ÷ 4 = 25M
Commission:
User A = 2 × 25M = 50M ✅ (not 33.33M!)
User B = 1 × 25M = 25M
User C = 1 × 25M = 25M
```
---
### **Week 3 (2025-W50) - With Carryover:**
**Tree Structure:**
```
User A
├─ Left: User B
│ ├─ Left: User D
│ │ └─ Left: User H (NEW - 25M)
│ └─ Right: User E
└─ Right: User C
├─ Left: User F
└─ Right: User G
```
**Calculations:**
```
User D:
leftNewMembers = 1 (User H)
rightNewMembers = 0
leftCarryover = 0
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 0 + 0 = 0
TotalBalances = MIN(1, 0) = 0
leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
rightRemainder = 0 - 0 = 0
User B:
leftNewMembers = 1 (H through D)
rightNewMembers = 0
leftCarryover = 0 (from week 2)
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 0 + 0 = 0
TotalBalances = MIN(1, 0) = 0
leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
rightRemainder = 0 - 0 = 0
User A:
leftNewMembers = 1 (H through B→D)
rightNewMembers = 0
leftCarryover = 0 (from week 2)
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 0 + 0 = 0
TotalBalances = MIN(1, 0) = 0
leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
rightRemainder = 0 - 0 = 0
```
**Pool Calculation:**
```
Total Pool = 25M (1 new activation)
Total Balances = 0 (no balanced pairs)
Value Per Balance = N/A
Commission: None this week
Carryover: User A, B, D each have 1 leftRemainder for week 4
```
---
## 🔄 Database Schema
### **NetworkWeeklyBalance Table:**
```sql
ALTER TABLE NetworkWeeklyBalances ADD:
-- New members this week
LeftLegNewMembers INT NOT NULL DEFAULT 0,
RightLegNewMembers INT NOT NULL DEFAULT 0,
-- Carryover from previous week
LeftLegCarryover INT NOT NULL DEFAULT 0,
RightLegCarryover INT NOT NULL DEFAULT 0,
-- Totals (new + carryover)
LeftLegTotal INT NOT NULL DEFAULT 0,
RightLegTotal INT NOT NULL DEFAULT 0,
-- Remainder for next week
LeftLegRemainder INT NOT NULL DEFAULT 0,
RightLegRemainder INT NOT NULL DEFAULT 0
```
**Deprecated Fields:**
- `LeftLegBalances` (still exists for backward compatibility)
- `RightLegBalances` (still exists for backward compatibility)
---
## 💻 Implementation
### **Handler: CalculateWeeklyBalancesCommandHandler.cs**
```csharp
public async Task<int> Handle(CalculateWeeklyBalancesCommand request, CancellationToken cancellationToken)
{
// 1. Load previous week's carryover
var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber);
var previousWeekCarryovers = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == previousWeekNumber)
.ToDictionaryAsync(x => x.UserId, x => new { x.LeftLegRemainder, x.RightLegRemainder });
// 2. For each user in network
foreach (var user in usersInNetwork)
{
// Get carryover
var leftCarryover = previousWeekCarryovers.ContainsKey(user.Id)
? previousWeekCarryovers[user.Id].LeftLegRemainder : 0;
var rightCarryover = previousWeekCarryovers.ContainsKey(user.Id)
? previousWeekCarryovers[user.Id].RightLegRemainder : 0;
// Count NEW members (activated in this week)
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekNumber);
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekNumber);
// Calculate totals
var leftTotal = leftNewMembers + leftCarryover;
var rightTotal = rightNewMembers + rightCarryover;
// Calculate balance (min)
var totalBalances = Math.Min(leftTotal, rightTotal);
// Calculate remainder
var leftRemainder = leftTotal - totalBalances;
var rightRemainder = rightTotal - totalBalances;
// Save to database
var balance = new NetworkWeeklyBalance
{
UserId = user.Id,
WeekNumber = request.WeekNumber,
LeftLegNewMembers = leftNewMembers,
RightLegNewMembers = rightNewMembers,
LeftLegCarryover = leftCarryover,
RightLegCarryover = rightCarryover,
LeftLegTotal = leftTotal,
RightLegTotal = rightTotal,
TotalBalances = totalBalances,
LeftLegRemainder = leftRemainder,
RightLegRemainder = rightRemainder,
// ...
};
}
}
private async Task<int> CountNewMembersRecursive(long userId, NetworkLeg leg, DateTime startDate, DateTime endDate)
{
var child = await _context.Users
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == leg);
if (child == null) return 0;
var count = 0;
// Check if activated in this week
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive);
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
{
count = 1;
}
// Recursively count children
var childLeft = await CountNewMembersRecursive(child.Id, NetworkLeg.Left, startDate, endDate);
var childRight = await CountNewMembersRecursive(child.Id, NetworkLeg.Right, startDate, endDate);
return count + childLeft + childRight;
}
```
---
## 📝 Key Points
1.**Only NEW activations count** - filtered by `ActivatedAt` date
2.**Carryover persists** - unused balances roll over to next week
3.**Recursive counting** - includes entire subtree under each leg
4.**Week date ranges** - ISO 8601 week format (Saturday to Friday)
5.**Idempotent** - can recalculate with `ForceRecalculate` flag
---
## 🚀 Benefits
1. **Fair commission distribution** - rewards balanced growth
2. **No lost balances** - carryover ensures nothing is wasted
3. **Accurate tracking** - distinguishes new vs existing members
4. **Scalable** - works for large networks with recursive algorithm
5. **Auditable** - full history of calculations in database
---
## 📞 Reference
- **Source Code**: `CMSMicroservice.Application/CommissionCQ/Commands/CalculateWeeklyBalances/`
- **Migration**: `20251201144400_UpdateNetworkWeeklyBalanceWithCarryover`
- **Entity**: `CMSMicroservice.Domain/Entities/Network/NetworkWeeklyBalance.cs`
- **Discussion**: Telegram chat with Dr. Seif (2025-12-01)
---
**Status**: ✅ Production Ready
**Last Updated**: 2025-12-01
+281
View File
@@ -0,0 +1,281 @@
# 🌳 Binary Tree Network Registration Guide
## 📋 Overview
از این پس، هر کاربر جدید که در سیستم ثبت می‌شود، **هم‌زمان** در دو ساختار قرار می‌گیرد:
1. **Old System**: `User.ParentId` (برای Backward Compatibility)
2. **New Binary Tree System**: `User.NetworkParentId` + `User.LegPosition` (Left/Right)
این تغییر تضمین می‌کند که:
- ✅ کاربران جدید بلافاصله در محاسبات Commission شرکت می‌کنند
- ✅ نیازی به Migration اضافی نیست
- ✅ Binary Tree Constraint رعایت می‌شود (حداکثر 2 فرزند)
---
## 🔧 Changes in Registration Flow
### قبل از تغییر:
```csharp
var entity = request.Adapt<User>();
entity.ReferralCode = UtilExtensions.Generate(digits: 10);
await _context.Users.AddAsync(entity, cancellationToken);
```
**مشکل**: فقط `ParentId` Set می‌شد، `NetworkParentId` و `LegPosition` خالی می‌ماند.
---
### بعد از تغییر:
```csharp
var entity = request.Adapt<User>();
entity.ReferralCode = UtilExtensions.Generate(digits: 10);
// === محاسبه موقعیت در Binary Tree ===
if (request.ParentId.HasValue)
{
var legPosition = await _networkPlacementService.CalculateLegPositionAsync(
request.ParentId.Value, cancellationToken);
if (legPosition.HasValue)
{
entity.NetworkParentId = request.ParentId.Value;
entity.LegPosition = legPosition.Value; // Left یا Right
}
else
{
// Parent پر است! Auto-Placement یا Error
var availableParent = await _networkPlacementService.FindAvailableParentAsync(
request.ParentId.Value, cancellationToken);
// ... Set کردن NetworkParentId و LegPosition با Parent جدید
}
}
await _context.Users.AddAsync(entity, cancellationToken);
```
**مزایا**:
-`NetworkParentId` و `LegPosition` به صورت خودکار محاسبه می‌شود
- ✅ Binary Tree Constraint چک می‌شود
- ✅ اگر Parent پر باشد، Auto-Placement انجام می‌شود
---
## 📐 Binary Tree Logic
### قوانین:
1. هر Parent فقط **2 فرزند** می‌تواند داشته باشد (Left & Right)
2. فرزند اول: `LegPosition = Left`
3. فرزند دوم: `LegPosition = Right`
4. اگر Parent پر باشد، سیستم به صورت BFS دنبال Parent خالی می‌گردد
### مثال:
```
User1 (Root)
/ \
User2 (L) User3 (R)
/ \
User4(L) User5(R)
```
- User2 → Parent=User1, Leg=Left
- User3 → Parent=User1, Leg=Right
- User4 → Parent=User2, Leg=Left
- User5 → Parent=User2, Leg=Right
اگر کاربر جدید با `ParentId=User1` بیاید:
- User1 پر است! (دو فرزند دارد)
- سیستم به User2 می‌رود (BFS)
- User2 هم پر است!
- به User3 می‌رود → User3 خالی است
- کاربر جدید → Parent=User3, Leg=Left
---
## 🛠️ NetworkPlacementService API
### 1. CalculateLegPositionAsync
محاسبه موقعیت (Left/Right) برای کاربر جدید زیر یک Parent مشخص.
```csharp
var legPosition = await _networkPlacementService.CalculateLegPositionAsync(parentId);
```
**Return Values**:
- `NetworkLeg.Left`: اگر Parent فرزند چپ ندارد
- `NetworkLeg.Right`: اگر Parent فرزند راست ندارد
- `null`: اگر Parent پر است (دو فرزند دارد)
---
### 2. CanAcceptChildAsync
بررسی اینکه آیا Parent می‌تواند فرزند جدید بپذیرد.
```csharp
bool canAccept = await _networkPlacementService.CanAcceptChildAsync(parentId);
```
**Return Values**:
- `true`: اگر Parent کمتر از 2 فرزند دارد
- `false`: اگر Parent پر است
---
### 3. FindAvailableParentAsync (Auto-Placement)
پیدا کردن اولین Parent خالی در Binary Tree با استفاده از BFS.
```csharp
long? availableParentId = await _networkPlacementService.FindAvailableParentAsync(rootParentId);
```
**Use Case**:
- زمانی که Parent مورد نظر پر است
- سیستم به صورت خودکار Parent جایگزین پیدا می‌کند
- از BFS استفاده می‌کند (Level-by-Level)
**Return Values**:
- `long`: شناسه Parent مناسب
- `null`: اگر هیچ Parent خالی پیدا نشد (تمام Binary Tree پر است!)
---
## ⚠️ Error Handling
### Scenario 1: Parent پر است و Auto-Placement موفق
```csharp
// Parent اصلی پر است
// سیستم Parent جدید پیدا می‌کند
_logger.LogWarning("Parent {ParentId} is full. Auto-placing under {NewParentId}");
```
**نتیجه**: کاربر با موفقیت در جای دیگری قرار می‌گیرد.
---
### Scenario 2: کل Binary Tree پر است
```csharp
throw new InvalidOperationException(
$"شبکه Parent با شناسه {parentId} پر است و نمی‌تواند کاربر جدید بپذیرد.");
```
**نتیجه**: Exception پرتاب می‌شود، ثبت کاربر انجام نمی‌شود.
**راه حل**:
- افزایش سطح Binary Tree
- یا تخصیص دستی Parent
---
### Scenario 3: Parent وجود ندارد
```csharp
var parentExists = await _context.Users.AnyAsync(u => u.Id == parentId);
if (!parentExists)
{
return null; // Parent نامعتبر
}
```
**نتیجه**: `null` برگردانده می‌شود، Exception پرتاب می‌شود.
---
## 📊 Logging & Monitoring
سیستم Log های زیر را می‌نویسد:
### Success:
```
User 123 placed in Binary Tree: Parent=45, Leg=Left
```
### Warning (Auto-Placement):
```
Parent 45 has no available leg! Finding alternative parent...
User 123 auto-placed under alternative Parent=67, Leg=Right
```
### Error (Binary Tree Full):
```
No available parent found in network for ParentId=45
```
---
## 🧪 Testing Scenarios
### Test 1: کاربر اول (Root)
```csharp
var command = new CreateNewUserCommand { Mobile = "09121234567" }; // No ParentId
// Result: ParentId=null, NetworkParentId=null, LegPosition=null
```
---
### Test 2: فرزند اول
```csharp
var command = new CreateNewUserCommand { Mobile = "09121234568", ParentId = 1 };
// Result: ParentId=1, NetworkParentId=1, LegPosition=Left
```
---
### Test 3: فرزند دوم
```csharp
var command = new CreateNewUserCommand { Mobile = "09121234569", ParentId = 1 };
// Result: ParentId=1, NetworkParentId=1, LegPosition=Right
```
---
### Test 4: فرزند سوم (Parent پر است)
```csharp
var command = new CreateNewUserCommand { Mobile = "09121234570", ParentId = 1 };
// Result: Auto-Placement → ParentId=1, NetworkParentId=2 (یا 3), LegPosition=Left
```
---
## 🔗 Related Files
- **Service Interface**: `CMSMicroservice.Application/Common/Interfaces/INetworkPlacementService.cs`
- **Service Implementation**: `CMSMicroservice.Infrastructure/Services/NetworkPlacementService.cs`
- **Handler**: `CMSMicroservice.Application/UserCQ/Commands/CreateNewUser/CreateNewUserCommandHandler.cs`
- **DI Registration**: `CMSMicroservice.Infrastructure/ConfigureServices.cs` (خط 23)
---
## ✅ Checklist
- [x] `INetworkPlacementService` اضافه شد
- [x] `NetworkPlacementService` پیاده‌سازی شد
- [x] DI Container تنظیم شد
- [x] `CreateNewUserCommandHandler` اصلاح شد
- [ ] Unit Tests نوشته شود
- [ ] Integration Tests انجام شود
- [ ] Manual Testing با Postman/gRPC Client
---
## 🚀 Next Steps
1. **Test کردن**: ثبت چند کاربر با Parent مشابه و بررسی LegPosition
2. **Load Testing**: بررسی Performance با 10,000 کاربر
3. **Edge Cases**: تست Binary Tree Full scenario
4. **Documentation**: Update کردن API Docs
---
## 📞 Support
اگر مشکلی پیش آمد:
- Log های `NetworkPlacementService` را بررسی کنید
- چک کنید که DI به درستی تنظیم شده باشد
- از `CanAcceptChildAsync` برای Pre-Validation استفاده کنید
+609
View File
@@ -0,0 +1,609 @@
# Daya Loan Integration System (سیستم یکپارچه‌سازی وام دایا)
## 📌 Overview
سیستم یکپارچه‌سازی با سرویس وام دایا برای شارژ خودکار کیف پول کاربران که وام دایا دریافت کرده‌اند.
**مقادیر شارژ:**
- **کیف پول اصلی (Balance)**: 56,000,000 تومان
- **کیف پول شبکه/کارمزد (NetworkBalance)**: 56,000,000 تومان
- **کیف پول تخفیف (DiscountBalance)**: 56,000,000 تومان
- **مجموع**: 168,000,000 تومان
**نکته مهم:** کیف پول باشگاه (ClubWallet) باید توسط کاربر در فرانت‌آفیس به صورت دستی شارژ شود.
---
## 🗂️ Architecture
### Domain Layer
#### **DayaLoanStatus Enum**
```csharp
public enum DayaLoanStatus
{
PendingReceive = 0, // در انتظار دریافت وام
Received = 1, // وام دریافت شده (آینده)
Rejected = 2 // رد شده (آینده)
}
```
#### **DayaLoanContract Entity**
```csharp
public class DayaLoanContract : BaseAuditableEntity
{
public long UserId { get; set; }
public string NationalCode { get; set; }
public string? ContractNumber { get; set; }
public DayaLoanStatus Status { get; set; }
public bool IsProcessed { get; set; }
public DateTime? LastCheckDate { get; set; }
public DateTime? ProcessedDate { get; set; }
public long? TransactionId { get; set; }
// Navigation Properties
public virtual User User { get; set; }
public virtual Transactions? Transaction { get; set; }
}
```
#### **User Entity Extensions**
```csharp
public class User : BaseAuditableEntity
{
// ... existing properties ...
public bool HasReceivedDayaCredit { get; set; }
public DateTime? DayaCreditReceivedAt { get; set; }
public virtual ICollection<DayaLoanContract>? DayaLoanContracts { get; set; }
}
```
---
### Application Layer
#### **Commands**
##### 1. ProcessDayaLoanApprovalCommand
شارژ کیف پول کاربر بعد از تایید وام دایا
**Request:**
```csharp
public record ProcessDayaLoanApprovalCommand : IRequest<ProcessDayaLoanApprovalResponseDto>
{
public long UserId { get; init; }
public string ContractNumber { get; init; }
public long WalletAmount { get; init; } = 56_000_000;
public long LockedWalletAmount { get; init; } = 56_000_000;
public long DiscountWalletAmount { get; init; } = 56_000_000;
}
```
**Response:**
```csharp
public class ProcessDayaLoanApprovalResponseDto
{
public long UserId { get; set; }
public long TransactionId { get; set; }
public string ContractNumber { get; set; }
public long MainWalletBalance { get; set; }
public long LockedWalletBalance { get; set; }
public long DiscountWalletBalance { get; set; }
public string Message { get; set; }
}
```
**Business Logic:**
1. بررسی اینکه کاربر قبلاً اعتبار دایا را دریافت نکرده باشد
2. ایجاد Transaction با:
- Type: DepositExternal1
- Amount: 168M تومان
- RefId: شماره قرارداد دایا
3. شارژ سه نوع کیف پول (Balance, NetworkBalance, DiscountBalance)
4. ثبت UserWalletChangeLog برای Balance و NetworkBalance (⚠️ DiscountBalance لاگ ندارد)
5. به‌روزرسانی فلگ‌های کاربر (HasReceivedDayaCredit, DayaCreditReceivedAt)
6. انتشار DayaLoanApprovedEvent
##### 2. CheckDayaLoanStatusCommand
استعلام وضعیت وام از سرویس دایا
**Request:**
```csharp
public record CheckDayaLoanStatusCommand : IRequest<CheckDayaLoanStatusResponseDto>
{
public List<string> NationalCodes { get; init; }
}
```
**Response:**
```csharp
public class CheckDayaLoanStatusResponseDto
{
public List<DayaLoanCheckResult> Results { get; set; }
public int TotalChecked { get; set; }
public int SuccessCount { get; set; }
}
public class DayaLoanCheckResult
{
public string NationalCode { get; set; }
public DayaLoanStatus Status { get; set; }
public string? ContractNumber { get; set; }
}
```
**⚠️ Current Status:** این Command فعلاً skeleton است و API واقعی دایا پیاده‌سازی نشده.
---
### Infrastructure Layer
#### **Background Worker: DayaLoanCheckWorker**
Worker خودکار که هر 15 دقیقه کاربران با وام pending را چک می‌کند.
**Location:** `CMSMicroservice.WebApi/Workers/DayaLoanCheckWorker.cs`
**Schedule:** `*/15 * * * *` (هر 15 دقیقه)
**Logic:**
1. Query کاربرانی که `HasReceivedDayaCredit == false` و دارای `NationalCode` هستند
2. فراخوانی `CheckDayaLoanStatusCommand` با لیست کدملی‌ها
3. برای هر نتیجه با Status=PendingReceive و ContractNumber موجود:
- فراخوانی `ProcessDayaLoanApprovalCommand`
- لاگ نتیجه عملیات
4. Retry خودکار در صورت خطا (Hangfire AutomaticRetry)
**Registration:** در `Program.cs` ثبت شده است:
```csharp
DayaLoanCheckWorker.Schedule(recurringJobManager);
```
---
## 🔄 Process Flow
```
1. کاربر درخواست وام دایا می‌دهد (خارج از سیستم)
2. Worker هر 15 دقیقه کاربران pending را چک می‌کند
3. CheckDayaLoanStatusCommand → فراخوانی API دایا
4. اگر Status = PendingReceive و ContractNumber موجود بود:
5. ProcessDayaLoanApprovalCommand اجرا می‌شود:
- ایجاد Transaction (168M تومان)
- شارژ Balance (+56M)
- شارژ NetworkBalance (+56M)
- شارژ DiscountBalance (+56M)
- ثبت WalletChangeLog (برای Balance و NetworkBalance)
- تنظیم HasReceivedDayaCredit = true
6. DayaLoanApprovedEvent منتشر می‌شود
7. EventHandler می‌تواند عملیات جانبی انجام دهد (مثل ارسال اطلاع‌رسانی)
```
---
## 💾 Database Schema
### DayaLoanContracts Table
```sql
CREATE TABLE [CMS].[DayaLoanContracts] (
[Id] bigint IDENTITY(1,1) PRIMARY KEY,
[UserId] bigint NOT NULL FOREIGN KEY REFERENCES Users(Id),
[NationalCode] nvarchar(max) NOT NULL,
[ContractNumber] nvarchar(max) NULL,
[Status] int NOT NULL,
[IsProcessed] bit NOT NULL,
[LastCheckDate] datetime2 NULL,
[ProcessedDate] datetime2 NULL,
[TransactionId] bigint NULL FOREIGN KEY REFERENCES Transactionss(Id),
[Created] datetime2 NOT NULL,
[CreatedBy] nvarchar(max) NULL,
[LastModified] datetime2 NULL,
[LastModifiedBy] nvarchar(max) NULL,
[IsDeleted] bit NOT NULL
);
```
### User Table Extensions
```sql
ALTER TABLE [CMS].[Users]
ADD [HasReceivedDayaCredit] bit NOT NULL DEFAULT 0,
[DayaCreditReceivedAt] datetime2 NULL;
```
**Migration:** `20251201191716_AddDayaLoanIntegration.cs`
---
## ⚠️ Important Notes
### ⚠️ CRITICAL: Don't Remove Business Logic on Errors!
- **وقتی با خطا مواجه شدیم، NEVER پاک نکنید بخشی از بیزینس را**
- **اول 5 بار تلاش کنید که خطا را برطرف کنید**
- اگر خطا برطرف نشد، آن را به حال خود رها کنید (Comment + TODO)
- Developer دستی خطا را بررسی و حل خواهد کرد
**مثال درست:**
```csharp
// TODO: این قسمت خطا دارد - نیاز به بررسی
// Error: CS1234 - Type not found
// var discountLog = new UserWalletChangeLog { ... };
// await _context.UserWalletChangeLogs.AddAsync(discountLog);
```
**مثال غلط (ممنوع!):**
```csharp
// ❌ پاک کردن لاگ DiscountBalance برای حل خطا - WRONG!
// این کار باعث از دست رفتن بخشی از بیزینس می‌شود
```
### 1. UserWalletChangeLog Limitation
- فیلدهای موجود: `CurrentBalance`, `ChangeValue`, `CurrentNetworkBalance`, `ChangeNerworkValue`
- **مشکل:** فیلدی برای `DiscountBalance` وجود ندارد
- **راه‌حل فعلی:** تغییرات DiscountBalance در لاگ ثبت نمی‌شود، فقط در جدول UserWallets ذخیره می‌شود
- **پیشنهاد آینده:** اضافه کردن فیلدهای `CurrentDiscountBalance` و `ChangeDiscountValue` به UserWalletChangeLog
### 2. Daya API Integration
- **وضعیت فعلی:** CheckDayaLoanStatusCommandHandler یک skeleton است
- **TODO:** پیاده‌سازی API واقعی دایا در Handler
- **Placeholder Code:**
```csharp
// TODO: فراخوانی سرویس دایا
// در حال حاضر داده Mock برمی‌گردانیم
```
### 3. Transaction Type
- از `TransactionType.DepositExternal1` استفاده می‌شود
- `RefId` = شماره قرارداد دایا
- این اطلاعات برای پیگیری و تطبیق با دایا ضروری است
### 4. One-Time Credit
- هر کاربر فقط **یک بار** می‌تواند اعتبار دایا دریافت کند
- بررسی توسط `HasReceivedDayaCredit` flag
- تلاش برای دریافت مجدد با خطا مواجه می‌شود
---
## 🧪 Testing
### Manual Testing via Hangfire Dashboard
1. به Hangfire Dashboard بروید: `/hangfire`
2. در بخش "Recurring Jobs" job با نام `daya-loan-check` را پیدا کنید
3. دکمه "Trigger now" را بزنید
4. در بخش "Jobs" می‌توانید لاگ‌ها را ببینید
### Testing Commands via gRPC (آینده)
```bash
# فراخوانی ProcessDayaLoanApproval
grpcurl -d '{
"userId": 123,
"contractNumber": "DAYA-12345"
}' localhost:5001 ProcessDayaLoanApproval
# فراخوانی CheckDayaLoanStatus
grpcurl -d '{
"nationalCodes": ["1234567890"]
}' localhost:5001 CheckDayaLoanStatus
```
---
## 📋 Pending Tasks
### High Priority
- [ ] پیاده‌سازی API واقعی دایا در CheckDayaLoanStatusCommandHandler
- [ ] اضافه کردن Proto definitions برای Daya commands
- [ ] اضافه کردن gRPC service endpoints
- [ ] تست Worker در محیط development
### Medium Priority
- [ ] ایجاد BFF handlers برای عملیات دایا
- [ ] ایجاد صفحات BackOffice برای مدیریت وام دایا
- [ ] اضافه کردن فیلتر برای مشاهده کاربران با وام دایا
- [ ] نمایش تاریخچه Daya Loan Contracts
### Low Priority
- [ ] اضافه کردن Unit Tests برای ProcessDayaLoanApprovalCommand
- [ ] اضافه کردن Integration Tests برای DayaLoanCheckWorker
- [ ] اضافه کردن Monitoring/Alerting برای خطاهای API دایا
- [ ] بهینه‌سازی Query برای یافتن کاربران pending
- [ ] اضافه کردن فیلدهای DiscountBalance به UserWalletChangeLog
---
## 🔗 Related Files
### Domain
- `CMSMicroservice.Domain/Enums/DayaLoanStatus.cs`
- `CMSMicroservice.Domain/Entities/DayaLoanContract.cs`
- `CMSMicroservice.Domain/Entities/User.cs` (updated)
- `CMSMicroservice.Domain/Events/DayaLoanApprovedEvent.cs`
### Application
- `CMSMicroservice.Application/DayaLoanCQ/Commands/ProcessDayaLoanApproval/`
- ProcessDayaLoanApprovalCommand.cs
- ProcessDayaLoanApprovalCommandHandler.cs
- ProcessDayaLoanApprovalCommandValidator.cs
- ProcessDayaLoanApprovalResponseDto.cs
- `CMSMicroservice.Application/DayaLoanCQ/Commands/CheckDayaLoanStatus/`
- CheckDayaLoanStatusCommand.cs
- CheckDayaLoanStatusCommandHandler.cs
- CheckDayaLoanStatusResponseDto.cs
- `CMSMicroservice.Application/DayaLoanCQ/EventHandlers/`
- DayaLoanApprovedEventHandler.cs
### Infrastructure
- `CMSMicroservice.Infrastructure/Persistence/ApplicationDbContext.cs` (updated)
- `CMSMicroservice.Infrastructure/Persistence/Migrations/20251201191716_AddDayaLoanIntegration.cs`
### WebApi
- `CMSMicroservice.WebApi/Workers/DayaLoanCheckWorker.cs`
- `CMSMicroservice.WebApi/Program.cs` (updated)
---
## 🧪 Testing
### Manual Testing
#### 1. ایجاد کاربر تست با کدملی شروع شده با "1"
```sql
-- کاربری که Mock Service برایش وام تایید می‌کند
INSERT INTO CMS.Users (NationalCode, FirstName, LastName, Mobile, HasReceivedDayaCredit)
VALUES ('1234567890', 'Test', 'User', '09121234567', 0);
```
#### 2. اجرای دستی Worker از Hangfire Dashboard
- باز کردن: `https://localhost:5001/hangfire`
- انتخاب Job: `daya-loan-check`
- کلیک روی "Trigger now"
#### 3. بررسی Logs
```bash
# در Console پروژه CMS
[INFO] DayaLoanCheckWorker started at 2024-12-02 10:30:00
[INFO] Found 1 users with pending Daya loan status
[WARN] ⚠️ Using MOCK Daya API Service - Replace with real implementation!
[INFO] Mock Daya API returned 1 results
[INFO] Daya loan processed for user 123. Contract: MOCK-DAYA-1234567890-638123456789
[INFO] DayaLoanCheckWorker completed. Checked: 1, Processed: 1
```
#### 4. بررسی Database
```sql
-- چک کردن DayaLoanContract
SELECT * FROM CMS.DayaLoanContracts WHERE NationalCode = '1234567890';
-- چک کردن UserWallet
SELECT * FROM CMS.UserWallets WHERE UserId = 123;
-- Balance باید 56,000,000 باشد
-- NetworkBalance باید 56,000,000 باشد
-- DiscountBalance باید 56,000,000 باشد
-- چک کردن Transaction
SELECT * FROM CMS.Transactionss WHERE RefId LIKE 'MOCK-DAYA-%';
-- Amount باید 168,000,000 باشد
-- چک کردن User Flag
SELECT HasReceivedDayaCredit, DayaCreditReceivedAt FROM CMS.Users WHERE Id = 123;
-- HasReceivedDayaCredit باید 1 باشد
```
#### 5. تست Mock Service Scenarios
```csharp
// کدملی شروع با "1" → PendingReceive + ContractNumber
// کدملی شروع با "2" → Rejected
// سایر کدملی‌ها → PendingReceive (بدون ContractNumber)
```
### Integration Testing با Real API
زمانی که API واقعی دایا آماده شد:
1. **تغییر ConfigureServices:**
```csharp
// در CMSMicroservice.Infrastructure/ConfigureServices.cs
services.AddScoped<IDayaLoanApiService, DayaLoanApiService>(); // Real
// services.AddScoped<IDayaLoanApiService, MockDayaLoanApiService>(); // Mock - حذف شود
```
2. **تنظیم HttpClient:**
```csharp
services.AddHttpClient<IDayaLoanApiService, DayaLoanApiService>(client =>
{
client.BaseAddress = new Uri(configuration["DayaApi:BaseUrl"]);
client.Timeout = TimeSpan.FromSeconds(30);
});
```
3. **اضافه کردن به appsettings.json:**
```json
{
"DayaApi": {
"BaseUrl": "https://api.daya.ir",
"ApiKey": "YOUR_API_KEY_HERE"
}
}
```
---
## 🐛 Troubleshooting
### مشکل: Worker اجرا نمی‌شود
**علت احتمالی:** Hangfire Server شروع نشده
**راه حل:**
```csharp
// در Program.cs چک کنید که این خط وجود دارد:
builder.Services.AddHangfireServer();
```
---
### مشکل: کاربران پیدا نمی‌شوند
**علت احتمالی:** همه کاربران قبلاً اعتبار دریافت کرده‌اند
**راه حل:**
```sql
-- Reset کردن وضعیت کاربران برای تست
UPDATE CMS.Users SET HasReceivedDayaCredit = 0, DayaCreditReceivedAt = NULL;
```
---
### مشکل: کیف پول شارژ نمی‌شود
**علت احتمالی:** کاربر کیف پول ندارد
**راه حل:**
```csharp
// کد Handler خودکار UserWallet می‌سازد اگر موجود نباشد:
if (wallet == null)
{
wallet = new UserWallet { UserId = request.UserId, Balance = 0, ... };
await _context.UserWallets.AddAsync(wallet, cancellationToken);
}
```
---
### مشکل: Mock API همیشه نتیجه یکسان برمی‌گرداند
**راه حل:** کدملی کاربر را تغییر دهید:
- کدملی شروع با **"1"** → وام تایید می‌شود ✅
- کدملی شروع با **"2"** → وام رد می‌شود ❌
- سایر → در انتظار (بدون ContractNumber) ⏳
---
### مشکل: Exception در ProcessDayaLoanApproval
**خطای احتمالی:** `User has already received Daya credit`
**علت:** کاربر قبلاً اعتبار دریافت کرده
**راه حل:**
```sql
-- فقط برای محیط Development
UPDATE CMS.Users SET HasReceivedDayaCredit = 0 WHERE Id = 123;
```
---
### مشکل: Migration اعمال نمی‌شود
**راه حل:**
```bash
cd CMS/src/CMSMicroservice.WebApi
dotnet ef database update
```
یا در Package Manager Console:
```powershell
Update-Database
```
---
## 📊 Monitoring
### Hangfire Dashboard
**URL:** `https://localhost:5001/hangfire`
**Metrics:**
- Succeeded jobs
- Failed jobs
- Processing jobs
- Scheduled jobs
**Job Details:**
- Job ID: `daya-loan-check`
- Schedule: `*/15 * * * *` (Every 15 minutes)
- Next Run: نمایش داده می‌شود در Dashboard
### Application Logs
**Successful Run:**
```
[INFO] DayaLoanCheckWorker started at {Time}
[INFO] Found {Count} users with pending Daya loan status
[INFO] Daya loan processed for user {UserId}. Contract: {ContractNumber}
[INFO] DayaLoanCheckWorker completed. Checked: {Total}, Processed: {Success}
```
**Error Scenarios:**
```
[ERROR] Error processing Daya loan for user {UserId}
[ERROR] Error calling Daya API service
[ERROR] Error in DayaLoanCheckWorker
```
---
## 🔒 Security Considerations
1. **API Key Management:**
- هرگز API Key را در کد Commit نکنید
- از User Secrets برای Development استفاده کنید
- از Azure Key Vault یا مشابه برای Production استفاده کنید
2. **Rate Limiting:**
- Worker هر 15 دقیقه اجرا می‌شود → حداکثر 96 بار در روز
- اگر API دایا محدودیت دارد، باید تنظیم شود
3. **Data Validation:**
- کدملی باید 10 رقمی باشد
- فقط یک بار برای هر کاربر پردازش می‌شود
---
## 📈 Performance Optimization
### Batch Processing
اگر تعداد کاربران زیاد باشد، می‌توان Query را بهینه کرد:
```csharp
// پردازش دسته‌ای (100 کاربر در هر بار)
var pendingUsers = await _context.Users
.Where(u => u.HasReceivedDayaCredit == false && u.NationalCode != null)
.Take(100) // Limit
.Select(u => new { u.Id, u.NationalCode })
.ToListAsync();
```
### Caching
می‌توان نتایج API را برای مدت کوتاهی Cache کرد:
```csharp
// Cache result for 5 minutes
[MemoryCache]
public async Task<List<DayaLoanStatusResult>> CheckLoanStatusAsync(...)
```
---
## 📚 References
- [Hangfire Documentation](https://docs.hangfire.io/)
- [MediatR Pattern](https://github.com/jbogard/MediatR)
- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
---
**Created:** 2024-12-01
**Last Updated:** 2024-12-02
**Status:** ✅ 100% Implemented (Mock API in use - Real API integration pending)
**Migration:** `20251201191716_AddDayaLoanIntegration`
**Test Coverage:** Manual testing documented
**Next Steps:** Replace MockDayaLoanApiService with real API implementation when available
+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
**وضعیت:** ✅ تایید شده توسط کاربر
+544
View File
@@ -0,0 +1,544 @@
# 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
#### **ManualPaymentStatus Enum**
```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 (برای کاربران بدون وام دایا ضروری است)
+905
View File
@@ -0,0 +1,905 @@
# سیستم باشگاه مشتریان و محاسبه کمیسیون شبکه
## خلاصه اجرایی
این سند تحلیل جامع و معماری پیشنهادی برای پیاده‌سازی سیستم باشگاه مشتریان (Club Membership) و محاسبه کمیسیون شبکه‌ای (MLM Binary Plan) را ارائه می‌دهد. این سیستم امکان مدیریت سه نوع کیف پول، فروشگاه اختصاصی با تخفیف، و توزیع عادلانه کمیسیون بر اساس تعادل شبکه را فراهم می‌کند.
---
## ۱. مفاهیم کلیدی
### ۱.۱ کیف پول‌های سه‌گانه
هر کاربر سه نوع کیف پول دارد:
1. **کیف پول اصلی (Balance)**: برای خرید از فروشگاه عمومی بازار
2. **کیف پول تخفیف (DiscountBalance)**: فقط برای خرید از فروشگاه باشگاه مشتریان (محدود به درصد تخفیف محصولات)
3. **کیف پول طلایی/کارمزد (NetworkBalance)**: دریافتی از کمیسیون شبکه‌ای - قابل برداشت نقدی یا خرید الماس از دایا
### ۱.۲ فعال‌سازی عضویت
- کاربر ۵۶ میلیون تومان پرداخت می‌کند (از طریق دایا یا درگاه)
- سیستم به صورت خودکار:
- `Balance += 56M` (کیف پول اصلی)
- `DiscountBalance += 56M` (کیف پول تخفیف)
- کاربر دکمه «عضویت در باشگاه» را می‌زند:
- `25M` به استخر کمیسیون هفتگی اضافه می‌شود
- کاربر در شبکه باینری (Binary Tree) قرار می‌گیرد
### ۱.۳ شبکه باینری (Binary MLM Plan)
- هر کاربر حداکثر دو زیرمجموعه دارد: **دست راست** و **دست چپ**
- تعادل (Balance): زمانی که هر دو شاخه دارای اعضای جدید شوند، یک تعادل ایجاد می‌شود
- **فرمول تعادل**: `UserBalances = MIN(LeftLegBalances, RightLegBalances)`
- تعادل‌ها به صورت هفتگی محاسبه و بعد از توزیع کمیسیون، ریست می‌شوند
### ۱.۴ محاسبه کمیسیون هفتگی
```text
مبلغ ریالی هر امتیاز = (مجموع مبالغ استخر) ÷ (مجموع تعادل‌های کل سیستم)
کمیسیون هر کاربر = (تعداد تعادل کاربر) × (مبلغ ریالی هر امتیاز)
```
**مثال**:
- کاربر A: خودش ۱ تعادل + زیرمجموعه‌هایش ۲ تعادل = **۳ امتیاز**
- استخر هفتگی: `175M`
- مجموع امتیازهای سیستم: `5`
- ارزش هر امتیاز: `175M ÷ 5 = 35M`
- کمیسیون کاربر A: `3 × 35M = 105M`
---
## ۲. موجودیت‌های جدید (Domain Entities)
### ۲.۱ `ClubMembership` (عضویت باشگاه مشتریان)
```csharp
public class ClubMembership : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; }
// مبلغ اولیه پرداختی برای فعال‌سازی (معمولاً ۲۵ میلیون)
public long InitialContribution { get; set; }
// مجموع درآمد کارمزد تاکنون
public long TotalEarned { get; set; }
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
}
```
### ۲.۲ `ClubFeature` (امکانات باشگاه)
```csharp
public class ClubFeature : BaseAuditableEntity
{
public string Title { get; set; }
public string? Description { get; set; }
public bool IsActive { get; set; }
public int? RequiredPoints { get; set; }
public int SortOrder { get; set; }
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
}
```
### ۲.۳ `UserClubFeature` (امتیاز/فیچرهای فعال برای کاربر)
```csharp
public class UserClubFeature : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
public long ClubFeatureId { get; set; }
public virtual ClubFeature ClubFeature { get; set; }
public DateTime GrantedAt { get; set; }
public string? Notes { get; set; }
}
```
### ۲.۴ `NetworkWeeklyBalance` (تعادل هفتگی شبکه)
```csharp
public class NetworkWeeklyBalance : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
// مثلاً "2025-W48"
public string WeekNumber { get; set; }
public int LeftLegBalances { get; set; }
public int RightLegBalances { get; set; }
public int TotalBalances { get; set; }
// مبلغی که این کاربر همان هفته به استخر اضافه کرده (معمولاً InitialContribution)
public long WeeklyPoolContribution { get; set; }
public DateTime? CalculatedAt { get; set; }
public bool IsExpired { get; set; }
}
```
### ۲.۵ `WeeklyCommissionPool` (استخر کمیسیون هفتگی)
```csharp
public class WeeklyCommissionPool : BaseAuditableEntity
{
public string WeekNumber { get; set; }
public long TotalPoolAmount { get; set; }
public int TotalBalances { get; set; }
public long ValuePerBalance { get; set; }
public bool IsCalculated { get; set; }
public DateTime? CalculatedAt { get; set; }
public virtual ICollection<UserCommissionPayout> UserCommissionPayouts { get; set; }
}
```
### ۲.۶ `UserCommissionPayout` (پرداخت کمیسیون به کاربر)
```csharp
public class UserCommissionPayout : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
public string WeekNumber { get; set; }
public long WeeklyPoolId { get; set; }
public virtual WeeklyCommissionPool WeeklyPool { get; set; }
public int BalancesEarned { get; set; }
public long ValuePerBalance { get; set; }
public long TotalAmount { get; set; }
public CommissionPayoutStatus Status { get; set; }
public DateTime? PaidAt { get; set; }
public WithdrawalMethod? WithdrawalMethod { get; set; }
public string? IbanNumber { get; set; }
public DateTime? WithdrawnAt { get; set; }
}
```
---
### ۲.۷ موجودیت‌های History (جداول لاگ)
#### ۲.۷.۱ `ClubMembershipHistory`
لاگ تغییرات مهم روی عضویت باشگاه (فعال‌سازی، غیرفعال‌سازی، ویرایش):
```csharp
public class ClubMembershipHistory : BaseAuditableEntity
{
public long ClubMembershipId { get; set; }
public long UserId { get; set; }
public bool OldIsActive { get; set; }
public bool NewIsActive { get; set; }
public long? OldInitialContribution { get; set; }
public long? NewInitialContribution { get; set; }
// Activated / Deactivated / Updated / ManualFix
public string Action { get; set; }
public string? Reason { get; set; }
}
```
#### ۲.۷.۲ `NetworkMembershipHistory`
برای اینکه همیشه بدانیم «چه کسی زیرمجموعه‌ی کی شده، چه زمانی، و اگر بعداً جابه‌جا شد چه اتفاقی افتاده»:
```csharp
public class NetworkMembershipHistory : BaseAuditableEntity
{
public long UserId { get; set; }
public long? OldParentId { get; set; }
public long? NewParentId { get; set; }
public NetworkLeg? OldLegPosition { get; set; }
public NetworkLeg? NewLegPosition { get; set; }
// Join / Move / Remove
public string Action { get; set; }
public string? Reason { get; set; }
}
```
- هر بار `RecordNetworkJoin` یا `UpdateNetworkPosition` صدا زده می‌شود، باید یک رکورد در این جدول نوشته شود.
- این جدول مرجع اصلی برای بازسازی درخت شبکه در زمان‌های گذشته است.
#### ۲.۷.۳ `CommissionPayoutHistory`
برای لاگ کامل همه‌ی تغییرات روی پرداخت کمیسیون‌ها (ایجاد، ویرایش دستی، تغییر وضعیت، برداشت و ...):
```csharp
public class CommissionPayoutHistory : BaseAuditableEntity
{
public long UserCommissionPayoutId { get; set; }
public long UserId { get; set; }
public string WeekNumber { get; set; }
public long AmountBefore { get; set; }
public long AmountAfter { get; set; }
public CommissionPayoutStatus OldStatus { get; set; }
public CommissionPayoutStatus NewStatus { get; set; }
// Created / Paid / WithdrawRequested / Withdrawn / Cancelled / ManualFix
public string Action { get; set; }
public string? PerformedBy { get; set; } // UserId یا System
public string? Reason { get; set; }
}
```
- اگر بعداً بفهمیم یک پرداخت اشتباه بوده و اصلاحش کنیم، اینجا قابل ردیابی است.
- برای گزارش‌گیری Audit کامل پرداخت‌ها، این جدول استفاده می‌شود.
#### ۲.۷.۴ `SystemConfigurationHistory`
تاریخچه تغییرات تنظیمات (Config) برای این‌که بعداً بدانیم در هر زمان چه محدودیتی فعال بوده:
```csharp
public class SystemConfigurationHistory : BaseAuditableEntity
{
public long ConfigurationId { get; set; }
public ConfigurationScope Scope { get; set; }
public string Key { get; set; }
public string OldValue { get; set; }
public string NewValue { get; set; }
public string? Reason { get; set; }
}
```
---
### ۲.۸ موجودیت‌های Configuration (تنظیمات پویا)
#### ۲.۸.۱ `ConfigurationScope` (Enum)
```csharp
public enum ConfigurationScope
{
System = 0,
Network = 1,
Club = 2,
Commission = 3
}
```
#### ۲.۸.۲ `SystemConfiguration`
جدولی برای نگهداری تنظیمات پویا. هم تنظیمات عمومی سیستم، هم تنظیمات مخصوص شبکه، باشگاه و کمیسیون:
```csharp
public class SystemConfiguration : BaseAuditableEntity
{
public ConfigurationScope Scope { get; set; } // System / Network / Club / Commission
// مثل: "MaxWeeklyBalancesPerUser", "MinContributionAmount", ...
public string Key { get; set; }
// مقدار به‌صورت رشته - تفسیر در لایه Application
public string Value { get; set; }
// برای UI و Validation (Int / Decimal / Bool / String / Json)
public string? DataType { get; set; }
public string? Description { get; set; }
public bool IsActive { get; set; }
}
```
**مثال کانفیگ‌های مرتبط با شبکه:**
- `Scope = Network`, `Key = "MaxWeeklyBalancesPerUser"`, `Value = "300"`
- `Scope = Network`, `Key = "MaxChildrenPerLeg"`, `Value = "1"`
- `Scope = Commission`, `Key = "DefaultInitialContribution"`, `Value = "25000000"`
> نکته: هر بار که مقدار `SystemConfiguration` تغییر می‌کند، یک رکورد در `SystemConfigurationHistory` ثبت می‌شود تا تنظیمات گذشته قابل ردیابی باشد.
---
### ۲.۹ Enums جدید
```csharp
public enum CommissionPayoutStatus
{
Pending = 0,
Paid = 1,
WithdrawRequested = 2,
Withdrawn = 3,
Cancelled = 4
}
public enum WithdrawalMethod
{
Cash = 0,
Diamond = 1
}
public enum NetworkLeg
{
Left = 0,
Right = 1
}
```
---
## ۳. تغییرات در موجودیت‌های موجود
### ۳.۱ `User`
افزودن فیلدهای مربوط به شبکه باینری و ناوبری:
```csharp
public class User : BaseAuditableEntity
{
// ...
public long? NetworkParentId { get; set; }
public virtual User? NetworkParent { get; set; }
public NetworkLeg? LegPosition { get; set; }
public virtual ICollection<User> NetworkChildren { get; set; }
public virtual ClubMembership? ClubMembership { get; set; }
public virtual ICollection<NetworkWeeklyBalance> NetworkWeeklyBalances { get; set; }
public virtual ICollection<UserCommissionPayout> CommissionPayouts { get; set; }
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
}
```
### ۳.۲ `UserWallet`
```csharp
public class UserWallet : BaseAuditableEntity
{
// موجودی ریالی اصلی
public long Balance { get; set; }
// موجودی شبکه/کارمزد (کیف پول طلایی)
public long NetworkBalance { get; set; }
// موجودی تخفیف (فقط برای خرید از فروشگاه باشگاه)
public long DiscountBalance { get; set; }
// ...
}
```
### ۳.۳ `Products`
```csharp
public class Product : BaseAuditableEntity
{
// ...
// آیا این محصول فقط در فروشگاه باشگاه موجود است
public bool IsClubExclusive { get; set; }
// درصد تخفیف باشگاه (0 تا 100)
public int ClubDiscountPercent { get; set; }
// ...
}
```
### ۳.۴ `UserWalletChangeLog`
افزودن نوع جدید تراکنش:
```csharp
public enum TransactionType
{
// ...
NetworkCommission = 10, // دریافت کمیسیون شبکه
ClubActivation = 11, // فعال‌سازی عضویت باشگاه
DiscountWalletCharge = 12, // شارژ کیف پول تخفیف
}
```
---
## ۴. معماری ماژول‌های جدید (Application / CQRS)
### ۴.۱ `ClubMembershipCQ/`
#### Commands
- **ActivateClubMembership**: فعال‌سازی عضویت باشگاه (کسر ۲۵ میلیون و اضافه به استخر)
- **DeactivateClubMembership**: غیرفعال‌سازی عضویت
- **UpdateClubMembership**: به‌روزرسانی اطلاعات عضویت
#### Queries
- **GetUserClubStatus**: دریافت وضعیت عضویت کاربر
- **GetAllClubMembersByFilter**: لیست اعضای باشگاه با فیلتر
### ۴.۲ `ClubFeatureCQ/`
#### Commands
- **CreateClubFeature**: ایجاد فیچر جدید
- **UpdateClubFeature**: ویرایش فیچر
- **DeleteClubFeature**: حذف فیچر
- **GrantFeatureToUser**: فعال‌سازی فیچر برای کاربر
- **RevokeFeatureFromUser**: غیرفعال‌سازی فیچر از کاربر
#### Queries
- **GetAllClubFeatures**: لیست تمام فیچرها
- **GetUserClubFeatures**: لیست فیچرهای فعال یک کاربر
### ۴.۳ `NetworkBalanceCQ/`
#### Commands
- **RecordNetworkJoin**: ثبت ورود کاربر به شبکه باینری (تعیین والد و شاخه)
- حتماً باید یک رکورد در `NetworkMembershipHistory` ایجاد کند.
- **UpdateNetworkPosition**: تغییر موقعیت در شبکه (مدیریتی)
- هر تغییر، یک رکورد History.
- **CalculateWeeklyBalances**: محاسبه تعادل‌های هفتگی (فراخوانی از Worker)
#### Queries
- **GetUserNetworkTree**: دریافت درخت زیرمجموعه‌های کاربر (چند سطح)
- **GetUserWeeklyBalances**: دریافت تعادل‌های هفتگی یک کاربر
- **GetNetworkStatistics**: آمار کلی شبکه (تعداد اعضا، عمق، تعادل)
### ۴.۴ `CommissionPoolCQ/`
#### Commands
- **InitializeWeeklyPool**: ایجاد استخر جدید برای هفته
- **AddToWeeklyPool**: افزودن مبلغ به استخر هفتگی (هنگام فعال‌سازی عضویت)
- **CalculatePoolValue**: محاسبه ارزش هر امتیاز
- **DistributeCommissions**: توزیع کمیسیون‌ها به کاربران (Worker)
- **CloseWeeklyPool**: بستن استخر پس از توزیع
#### Queries
- **GetCurrentWeekPool**: دریافت اطلاعات استخر هفته جاری
- **GetPoolHistory**: تاریخچه استخرهای قبلی با فیلتر
### ۴.۵ `CommissionPayoutCQ/`
#### Commands
- **CreatePayoutRecord**: ثبت پرداخت کمیسیون (اتوماتیک از Worker)
- همراه با ایجاد رکورد در `CommissionPayoutHistory` (Action = Created).
- **RequestWithdrawal**: درخواست برداشت کمیسیون (نقدی یا الماس)
- History با Action = WithdrawRequested.
- **ProcessWithdrawal**: پردازش درخواست برداشت (تایید/رد ادمین)
- تغییر Status + History.
- **CancelPayout**: لغو پرداخت
#### Queries
- **GetUserCommissionHistory**: تاریخچه کمیسیون‌های دریافتی کاربر
- **GetPendingWithdrawals**: لیست درخواست‌های برداشت در انتظار (برای ادمین)
- **GetCommissionSummary**: خلاصه درآمد کمیسیون (مجموع، ماهانه، سالانه)
### ۴.۶ `ConfigurationCQ/`
#### Commands
- **SetConfigurationValue**: ثبت/ویرایش یک تنظیم (SystemConfiguration)
- هر تغییر باید در `SystemConfigurationHistory` ثبت شود.
- **DeactivateConfiguration**: غیرفعال‌سازی یک تنظیم
#### Queries
- **GetConfigurationValue**: دریافت مقدار یک Key
- **GetConfigurationByScope**: لیست تنظیمات یک Scope (مثلاً Network)
---
## ۵. Background Worker/Job (محاسبات هفتگی)
### ۵.۱ `WeeklyNetworkCommissionWorker`
**زمان‌بندی**: هر یکشنبه ساعت ۲۳:۵۹ (یا دوشنبه ۰۰:۰۱)
**مراحل اجرایی (High-level):**
#### گام ۱: بستن هفته قبل و ایجاد استخر جدید
```csharp
var currentWeek = GetCurrentWeekNumber(); // مثلاً "2025-W48"
var previousWeek = GetPreviousWeekNumber();
await CloseWeeklyPool(previousWeek);
await InitializeWeeklyPool(currentWeek);
```
#### گام ۲: محاسبه تعادل‌های شبکه
```csharp
var maxBalancesPerUser = GetConfig<int>("MaxWeeklyBalancesPerUser", scope: ConfigurationScope.Network);
var activeMembers = await GetActiveClubMembers();
foreach (var member in activeMembers)
{
var leftBalances = await CalculateLegBalances(member.UserId, NetworkLeg.Left, previousWeek);
var rightBalances = await CalculateLegBalances(member.UserId, NetworkLeg.Right, previousWeek);
var totalBalances = Math.Min(leftBalances, rightBalances);
// اعمال محدودیت کانفیگ (مثلاً حداکثر 300 تعادل برای هر کاربر)
if (totalBalances > maxBalancesPerUser)
totalBalances = maxBalancesPerUser;
await RecordWeeklyBalance(new NetworkWeeklyBalance {
UserId = member.UserId,
WeekNumber = previousWeek,
LeftLegBalances = leftBalances,
RightLegBalances = rightBalances,
TotalBalances = totalBalances,
WeeklyPoolContribution = member.InitialContribution,
CalculatedAt = DateTime.UtcNow
});
}
```
#### الگوریتم بازگشتی محاسبه تعادل شاخه
```csharp
private async Task<int> CalculateLegBalances(long userId, NetworkLeg leg, string weekNumber)
{
var children = await GetNetworkChildren(userId, leg);
int totalBalances = 0;
foreach (var child in children)
{
var childMembership = await GetClubMembership(child.Id);
if (childMembership != null && IsInWeek(childMembership.ActivatedAt, weekNumber))
{
totalBalances++;
}
var childLeftBalances = await CalculateLegBalances(child.Id, NetworkLeg.Left, weekNumber);
var childRightBalances = await CalculateLegBalances(child.Id, NetworkLeg.Right, weekNumber);
totalBalances += Math.Min(childLeftBalances, childRightBalances);
}
return totalBalances;
}
```
#### گام ۳: محاسبه استخر و ارزش امتیاز
```csharp
var totalPoolAmount = await SumPoolContributions(previousWeek);
var totalBalances = await SumTotalBalances(previousWeek);
var valuePerBalance = totalBalances > 0 ? totalPoolAmount / totalBalances : 0;
await UpdatePoolValue(previousWeek, totalPoolAmount, totalBalances, valuePerBalance);
```
#### گام ۴: توزیع کمیسیون‌ها
```csharp
var weeklyBalances = await GetWeeklyBalances(previousWeek);
foreach (var balance in weeklyBalances.Where(b => b.TotalBalances > 0))
{
var payoutAmount = balance.TotalBalances * valuePerBalance;
var payout = new UserCommissionPayout {
UserId = balance.UserId,
WeekNumber = previousWeek,
BalancesEarned = balance.TotalBalances,
ValuePerBalance = valuePerBalance,
TotalAmount = payoutAmount,
Status = CommissionPayoutStatus.Pending
};
await CreatePayoutRecord(payout); // داخلش CommissionPayoutHistory هم ثبت می‌شود
await AddToNetworkBalance(balance.UserId, payoutAmount);
await RecordWalletChange(new UserWalletChangeLog {
WalletId = balance.UserId,
// PreviousBalance / AfterBalance پر می‌شود
Amount = payoutAmount,
TransactionType = TransactionType.NetworkCommission,
ReferenceId = payout.Id.ToString()
});
payout.Status = CommissionPayoutStatus.Paid;
payout.PaidAt = DateTime.UtcNow;
await UpdatePayout(payout);
await AddCommissionHistory(payout, "Paid");
}
```
#### گام ۵: ریست تعادل‌ها
```csharp
await ExpireWeeklyBalances(previousWeek);
```
---
## ۶. لاجیک فروشگاه و سبد خرید
### ۶.۱ نمایش محصولات
```csharp
var query = _context.Products.Where(p => !p.IsDeleted);
if (!user.ClubMembership?.IsActive ?? true)
{
query = query.Where(p => !p.IsClubExclusive);
}
// اگر کاربر عضو است، قیمت با تخفیف باشگاه محاسبه می‌شود
```
### ۶.۲ استفاده از کیف پول تخفیف در Checkout
(خلاصه‌سازی شده – در کد اصلی از DiscountBalance استفاده می‌شود و ChangeLog ثبت می‌گردد.)
---
## ۷. سناریوی کامل فعال‌سازی عضویت
### مرحله ۱: شارژ اولیه
```text
کاربر → پرداخت ۵۶ میلیون (دایا/درگاه)
UserWallet.Balance += 56,000,000
UserWallet.DiscountBalance += 56,000,000
```
### مرحله ۲: فعال‌سازی عضویت
```text
کاربر → کلیک روی دکمه «عضویت در باشگاه»
API: ActivateClubMembership
1. ایجاد رکورد ClubMembership:
- IsActive = true
- InitialContribution = 25,000,000
2. افزودن به استخر هفتگی:
- WeeklyCommissionPool.TotalPoolAmount += 25,000,000
3. تعیین موقعیت در شبکه:
- User.NetworkParentId = والد
- User.LegPosition = Left یا Right
4. ثبت ChangeLog برای استخر:
- TransactionType = ClubActivation
5. ثبت ClubMembershipHistory:
- Action = "Activated"
```
### مرحله ۳: محاسبه هفتگی (Worker)
(مطابق بخش ۵)
### مرحله ۴: برداشت کمیسیون
```text
کاربر → درخواست برداشت
API: RequestWithdrawal (Cash یا Diamond)
ادمین → تایید درخواست
1. اگر Cash:
- واریز به حساب بانکی
- NetworkBalance -= مبلغ
2. اگر Diamond:
- خرید الماس از دایا
- NetworkBalance -= مبلغ
```
همراه با ثبت رکورد در `CommissionPayoutHistory` (Action = WithdrawRequested / Withdrawn).
---
## ۸. پروتوباف و gRPC Services
### ۸.۱ `clubmembership.proto`
```protobuf
syntax = "proto3";
import "google/protobuf/timestamp.proto";
package clubmembership;
service ClubMembershipService {
rpc ActivateMembership (ActivateMembershipRequest) returns (ActivateMembershipResponse);
rpc GetClubStatus (GetClubStatusRequest) returns (GetClubStatusResponse);
rpc GrantFeature (GrantFeatureRequest) returns (GrantFeatureResponse);
rpc GetUserFeatures (GetUserFeaturesRequest) returns (GetUserFeaturesResponse);
}
message ActivateMembershipRequest {
int64 user_id = 1;
int64 contribution_amount = 2;
int64 network_parent_id = 3;
NetworkLeg leg_position = 4;
}
message ActivateMembershipResponse {
bool success = 1;
string message = 2;
ClubMembershipDto membership = 3;
}
message GetClubStatusRequest {
int64 user_id = 1;
}
message GetClubStatusResponse {
bool is_member = 1;
ClubMembershipDto membership = 2;
}
message ClubMembershipDto {
int64 id = 1;
int64 user_id = 2;
bool is_active = 3;
google.protobuf.Timestamp activated_at = 4;
int64 initial_contribution = 5;
int64 total_earned = 6;
}
enum NetworkLeg {
LEFT = 0;
RIGHT = 1;
}
```
### ۸.۲ `networkbalance.proto`
```protobuf
syntax = "proto3";
package networkbalance;
service NetworkBalanceService {
rpc GetNetworkTree (GetNetworkTreeRequest) returns (GetNetworkTreeResponse);
rpc GetWeeklyBalances (GetWeeklyBalancesRequest) returns (GetWeeklyBalancesResponse);
rpc GetNetworkStats (GetNetworkStatsRequest) returns (GetNetworkStatsResponse);
}
message GetNetworkTreeRequest {
int64 user_id = 1;
int32 max_depth = 2;
}
message GetNetworkTreeResponse {
NetworkNodeDto root = 1;
}
message NetworkNodeDto {
int64 user_id = 1;
string full_name = 2;
NetworkLeg leg_position = 3;
bool is_active = 4;
repeated NetworkNodeDto children = 5;
}
message GetWeeklyBalancesRequest {
int64 user_id = 1;
string week_number = 2;
}
message GetWeeklyBalancesResponse {
int32 left_leg_balances = 1;
int32 right_leg_balances = 2;
int32 total_balances = 3;
int64 pool_contribution = 4;
}
```
### ۸.۳ `commissionpayout.proto`
```protobuf
syntax = "proto3";
import "google/protobuf/timestamp.proto";
package commissionpayout;
service CommissionPayoutService {
rpc RequestWithdrawal (RequestWithdrawalRequest) returns (RequestWithdrawalResponse);
rpc GetCommissionHistory (GetCommissionHistoryRequest) returns (GetCommissionHistoryResponse);
rpc GetPendingWithdrawals (GetPendingWithdrawalsRequest) returns (GetPendingWithdrawalsResponse);
rpc ProcessWithdrawal (ProcessWithdrawalRequest) returns (ProcessWithdrawalResponse);
}
message RequestWithdrawalRequest {
int64 user_id = 1;
int64 amount = 2;
WithdrawalMethod method = 3;
string iban_number = 4;
}
message RequestWithdrawalResponse {
bool success = 1;
string message = 2;
int64 request_id = 3;
}
message GetCommissionHistoryRequest {
int64 user_id = 1;
int32 page_number = 2;
int32 page_size = 3;
}
message GetCommissionHistoryResponse {
repeated CommissionPayoutDto payouts = 1;
int32 total_count = 2;
}
message CommissionPayoutDto {
int64 id = 1;
string week_number = 2;
int32 balances_earned = 3;
int64 value_per_balance = 4;
int64 total_amount = 5;
CommissionPayoutStatus status = 6;
google.protobuf.Timestamp paid_at = 7;
WithdrawalMethod withdrawal_method = 8;
}
enum WithdrawalMethod {
CASH = 0;
DIAMOND = 1;
}
enum CommissionPayoutStatus {
PENDING = 0;
PAID = 1;
WITHDRAW_REQUESTED = 2;
WITHDRAWN = 3;
CANCELLED = 4;
}
```
---
## ۹. نکات حیاتی و بهترین رویه‌ها
### ۹.۱ یکپارچگی شبکه باینری
- هر کاربر حداکثر دو فرزند (یکی Left، یکی Right)
- هنگام اضافه کردن فرزند، کنترل Race Condition
- حذف کاربر نباید ساختار شبکه را خراب کند
### ۹.۲ Transaction Management
- Worker باید تمام مراحل را در یک TransactionScope انجام دهد
- در صورت شکست، Rollback کامل
### ۹.۳ Idempotency
- محاسبه هفتگی برای یک WeekNumber فقط یک‌بار
- بررسی `WeeklyCommissionPool.IsCalculated` قبل از شروع
### ۹.۴ Performance
- Caching درخت شبکه برای کاربران پرحجم
- Index روی `WeekNumber`, `UserId`, `NetworkParentId`
### ۹.۵ Audit و Compliance
- همه تغییرات کیف پول در `UserWalletChangeLog`
- همه پرداخت‌های کمیسیون در `UserCommissionPayout` + `CommissionPayoutHistory`
- تغییرات شبکه در `NetworkMembershipHistory`
- تغییرات تنظیمات در `SystemConfigurationHistory`
### ۹.۶ Security
- محدودیت تعداد درخواست برداشت
- تایید دو مرحله‌ای برای برداشت‌های بالا
- Audit Log برای عملیات حساس
---
## ۱۰. مراحل پیاده‌سازی (Roadmap)
(مطابق نسخه قبلی – فاز ۱ تا ۶)
---
## ۱۱. متریک‌های کلیدی (KPIs)
- تعداد اعضای فعال باشگاه
- مجموع کمیسیون‌های پرداختی هر ماه
- میانگین تعادل هر کاربر در هفته
- نرخ تبدیل به عضویت باشگاه
- زمان اجرای Worker، تعداد خطاها، عمق درخت، حجم داده History و …
---
## ۱۲. سوالات متداول (FAQ)
(همان سوالات قبلی + می‌توان سوالات مربوط به سقف تعادل و تنظیمات را اضافه کرد.)
---
## ۱۳. ضمیمه: مثال عددی کامل
(مثال دو هفته‌ای A, B, C, D, E, F, G مثل نسخه قبلی.)
---
## ۱۴. مسیرهای مرتبط
- Domain: `CMS/src/CMSMicroservice.Domain/Entities/`
- Application: `CMS/src/CMSMicroservice.Application/ClubMembershipCQ/`, `NetworkBalanceCQ/`, `CommissionPoolCQ/`, `CommissionPayoutCQ/`, `ConfigurationCQ/`
- Protobuf: `CMS/src/CMSMicroservice.Protobuf/Protos/`
- Worker: `CMS/src/CMSMicroservice.Infrastructure/BackgroundJobs/`
- مستند حاضر: `CMS/docs/network-club-commission-system.md`
**نسخه**: 1.1
**تاریخ**: 2025-11-29
**نویسنده**: تیم توسعه CMS
**وضعیت**: آماده پیاده‌سازی (با History و Config)
+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
**وضعیت:** ✅ تایید شده توسط کاربر