feat: Add ClearCart command and response, implement CancelOrder command with validation, and enhance DeliveryStatus and User models
This commit is contained in:
258
README.md
258
README.md
@@ -1,2 +1,258 @@
|
||||
# CMS
|
||||
# CMS Microservice - Network & Club Commission System
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
## 📊 Project Status (2025-12-01)
|
||||
|
||||
**Overall Progress**: 85% Complete (7/10 phases)
|
||||
**Production Readiness**: 95%
|
||||
**MVP Status**: ✅ 100% Complete
|
||||
|
||||
### ✅ Completed Phases (7)
|
||||
1. ✅ Domain Layer (Entities, Enums, Value Objects)
|
||||
2. ✅ Club Membership System
|
||||
3. ✅ Binary Network Tree
|
||||
4. ✅ **Commission Calculation & Background Worker** (MVP)
|
||||
5. ✅ Protobuf gRPC Services
|
||||
6. ✅ History & Configuration Management
|
||||
7. ✅ Database Migration & Seed Data
|
||||
|
||||
### 🟡 Partially Complete (1)
|
||||
- Phase 10: Withdrawal & Settlement (40%)
|
||||
- ✅ Commands & Database
|
||||
- ❌ Payment Gateway Integration
|
||||
|
||||
### ❌ Not Started (1)
|
||||
- Phase 9: Club Shop & Product Integration (0%)
|
||||
|
||||
### ⏸️ Postponed (1)
|
||||
- Phase 7: Testing (Unit, Integration, Load tests)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Recent Updates (2025-12-01)
|
||||
|
||||
### Email & SMS Notifications - COMPLETED ✅
|
||||
- ✅ **MailKit 4.14.1** for Email (SMTP with HTML templates)
|
||||
- ✅ **Kavenegar 1.2.5** for SMS (Iranian SMS gateway)
|
||||
- ✅ User.Email field added with migration
|
||||
- ✅ 3 notification types: Commission, Club activation, Errors
|
||||
- ✅ Persian RTL templates with rich formatting
|
||||
- ✅ Production configuration guide created
|
||||
|
||||
### Hangfire Job Scheduling - COMPLETED ✅
|
||||
- ✅ Dashboard UI at `/hangfire`
|
||||
- ✅ Cron schedule: Sunday 00:05 UTC
|
||||
- ✅ SQL Server persistence
|
||||
- ✅ Manual trigger API endpoints
|
||||
- ✅ Distributed execution support
|
||||
|
||||
### Infrastructure Enhancements - COMPLETED ✅
|
||||
- ✅ Health Check endpoints (`/health`, `/health/ready`, `/health/live`)
|
||||
- ✅ AlertService (structured logging for Sentry/Slack)
|
||||
- ✅ Retry logic (Polly 8.5.0 with exponential backoff)
|
||||
- ✅ WorkerExecutionLog (database audit trail)
|
||||
- ✅ CurrentUserService (JWT authentication context)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
**Clean Architecture** with 4 layers:
|
||||
```
|
||||
CMSMicroservice.Domain/ # Entities, Enums, Interfaces
|
||||
CMSMicroservice.Application/ # CQRS (Commands, Queries, MediatR)
|
||||
CMSMicroservice.Infrastructure/ # DbContext, Services, Background Jobs
|
||||
CMSMicroservice.WebApi/ # gRPC Services, Controllers
|
||||
CMSMicroservice.Protobuf/ # Protocol Buffers definitions
|
||||
```
|
||||
|
||||
**Technology Stack**:
|
||||
- .NET 9.0
|
||||
- Entity Framework Core 9.0.11
|
||||
- gRPC + JSON Transcoding
|
||||
- Hangfire 1.8.22 (Job Scheduling)
|
||||
- MediatR 13.0.0 (CQRS)
|
||||
- Polly 8.5.0 (Resilience)
|
||||
- MailKit 4.14.1 (Email)
|
||||
- Kavenegar 1.2.5 (SMS)
|
||||
- SQL Server
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **[Implementation Progress](docs/implementation-progress.md)** - Detailed phase-by-phase progress
|
||||
- **[Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)** - Production setup instructions
|
||||
- **[Balance Calculation Logic](docs/balance-calculation-carryover-logic.md)** - Commission algorithm details
|
||||
- **[Binary Tree Registration](docs/binary-tree-registration-guide.md)** - Network tree guide
|
||||
- **[Network Club Commission System](docs/network-club-commission-system-v1.1.md)** - Full system specification
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- .NET 9.0 SDK
|
||||
- SQL Server (local or remote)
|
||||
- (Optional) Gmail account for Email
|
||||
- (Optional) Kavenegar account for SMS
|
||||
|
||||
### 1. Clone & Build
|
||||
```bash
|
||||
cd /home/masoud/Apps/project/FourSat/CMS/src
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### 2. Configure Database
|
||||
Update `appsettings.json` with your SQL Server connection:
|
||||
```json
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=YOUR_SERVER;Database=Foursat_CMS;..."
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Apply Migrations
|
||||
```bash
|
||||
cd CMSMicroservice.WebApi
|
||||
dotnet ef database update
|
||||
```
|
||||
|
||||
### 4. Configure Notifications (Optional)
|
||||
See [Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)
|
||||
|
||||
### 5. Run
|
||||
```bash
|
||||
dotnet run --urls="http://localhost:5133"
|
||||
```
|
||||
|
||||
### 6. Access Endpoints
|
||||
- **Health**: http://localhost:5133/health
|
||||
- **Hangfire Dashboard**: http://localhost:5133/hangfire
|
||||
- **gRPC**: localhost:5133 (HTTP/2)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Email (SMTP)
|
||||
```json
|
||||
"Email": {
|
||||
"Enabled": true,
|
||||
"SmtpHost": "smtp.gmail.com",
|
||||
"SmtpPort": 587,
|
||||
"SmtpUsername": "your-email@gmail.com",
|
||||
"SmtpPassword": "your-gmail-app-password",
|
||||
"FromEmail": "noreply@foursat.com",
|
||||
"FromName": "FourSat CMS",
|
||||
"EnableSsl": true
|
||||
}
|
||||
```
|
||||
|
||||
### SMS (Kavenegar)
|
||||
```json
|
||||
"Sms": {
|
||||
"Enabled": true,
|
||||
"Provider": "Kavenegar",
|
||||
"KavenegarApiKey": "YOUR_API_KEY",
|
||||
"Sender": "10008663"
|
||||
}
|
||||
```
|
||||
|
||||
### Background Worker
|
||||
```csharp
|
||||
// Cron: "5 0 * * 0" = Every Sunday at 00:05 UTC
|
||||
RecurringJob.AddOrUpdate<WeeklyCommissionJob>(
|
||||
"weekly-commission-calculation",
|
||||
job => job.ExecuteAsync(CancellationToken.None),
|
||||
"5 0 * * 0");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Manual Trigger (via API)
|
||||
```bash
|
||||
# Trigger weekly calculation immediately
|
||||
curl -X POST http://localhost:5133/api/admin/trigger-weekly-calculation
|
||||
|
||||
# Trigger recurring job now
|
||||
curl -X POST http://localhost:5133/api/admin/trigger-recurring-job-now
|
||||
|
||||
# Get recurring jobs status
|
||||
curl http://localhost:5133/api/admin/recurring-jobs-status
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
```bash
|
||||
curl http://localhost:5133/health # Overall health
|
||||
curl http://localhost:5133/health/ready # Readiness probe (K8s)
|
||||
curl http://localhost:5133/health/live # Liveness probe (K8s)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 What's Remaining?
|
||||
|
||||
### High Priority
|
||||
1. **Payment Gateway Integration** (Phase 10 - 1 week)
|
||||
- Daya or Bank Mellat API integration
|
||||
- IBAN transfer automation
|
||||
- Admin approval UI in BackOffice
|
||||
|
||||
2. **Production Configuration** (30 minutes)
|
||||
- Gmail App Password setup
|
||||
- Kavenegar API key registration
|
||||
- Update `appsettings.Production.json`
|
||||
|
||||
### Medium Priority
|
||||
3. **Club Shop Integration** (Phase 9 - 2 weeks)
|
||||
- Product catalog for club memberships
|
||||
- Shopping cart integration
|
||||
- Auto-activation on purchase
|
||||
|
||||
### Low Priority
|
||||
4. **Testing** (Phase 7 - Postponed)
|
||||
- Unit tests for business logic
|
||||
- Integration tests for API
|
||||
- Load testing for background worker
|
||||
|
||||
### Optional Enhancements
|
||||
- Redis distributed locks (multi-server deployment)
|
||||
- Sentry error tracking (API key needed)
|
||||
- Slack notifications (webhook needed)
|
||||
- FCM push notifications
|
||||
|
||||
---
|
||||
|
||||
## 🎯 MVP Features (100% Complete)
|
||||
|
||||
✅ Binary network tree with automatic placement
|
||||
✅ Club membership (Member/Trial) with different commission rates
|
||||
✅ Weekly commission calculation (Lesser Leg algorithm)
|
||||
✅ Background worker with Hangfire (cron scheduling)
|
||||
✅ Balance carryover logic (rollover unused volumes)
|
||||
✅ MaxWeeklyBalances cap enforcement
|
||||
✅ Health check endpoints (Kubernetes-ready)
|
||||
✅ Manual trigger API (admin control)
|
||||
✅ Email + SMS notifications (MailKit + Kavenegar)
|
||||
✅ Retry logic with exponential backoff (Polly)
|
||||
✅ Audit trail (WorkerExecutionLog, History tables)
|
||||
✅ Structured logging (AlertService for Sentry/Slack)
|
||||
✅ JWT authentication context (CurrentUserService)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Team
|
||||
|
||||
**Development**: FourSat Team
|
||||
**Last Updated**: 2025-12-01
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
Proprietary - FourSat Company
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
# 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
|
||||
@@ -1,281 +0,0 @@
|
||||
# 🌳 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 استفاده کنید
|
||||
@@ -1,123 +0,0 @@
|
||||
# مستندات داده و بیزینس مایکروسرویس CMS
|
||||
|
||||
## معماری و لایهها
|
||||
- **پشته فنی**: .NET 9 + ASP.NET Core WebAPI، MediatR برای پیادهسازی CQRS، EF Core برای دسترسی داده، Mapster برای مپینگ DTO و gRPC/Protobuf برای قرارداد سرویس بین BFF ها و FrontOffice.
|
||||
- **ساختار پروژه**: لایههای Domain (موجودیت و قواعد)، Application (CQRS Commands/Queries، ولیدیشن، DTO)، Infrastructure (EF Core + سرویسهای جانبی) و WebApi (ورودی HTTP/gRPC) به همراه پروژه مستقل Protobuf جهت بهاشتراکگذاری قراردادها.
|
||||
- **الگوی کلی**: هر درخواست ورودی از طریق WebApi به MediatR ارسال و Handler مربوطه داده را از DbContext میخواند/مینویسد. تمام موجودیتها از `BaseAuditableEntity` ارث میبرند و ستونهای `Id`, `Created`, `CreatedBy`, `LastModified`, `IsDeleted` را به صورت یکپارچه فراهم میکنند.
|
||||
- **ملاحظات مقیاسپذیری**: Handler ها stateless هستند و میتوانند افقی مقیاس شوند. کنترل تراکنشها توسط EF Core انجام میشود و در عملیات چندمرحلهای (مثلاً ثبت سفارش) تغییرات داخل یک `TransactionScope` واحد اعمال میشود تا سازگاری داده حفظ شود.
|
||||
- **پایش و ردگیری**: رفتارهای `Common/Behaviours` برای لاگگیری و اعتبارسنجی فعالاند و برای هر درخواست یک شناسه ردگیری تولید میکنند تا ارتباط بین لاگ BackOffice و FrontOffice حفظ گردد.
|
||||
|
||||
## مدل داده
|
||||
برای فهم بهتر بیزینس، موجودیتها در پنج خوشه اصلی (هویت، کاتالوگ، سفارش، کیف پول، قرارداد) دستهبندی شدهاند و هر خوشه قواعد و قیود مخصوص خود را دارد.
|
||||
### لایه کاربر و هویت
|
||||
- **User**: اطلاعات هویتی، وضعیت تایید موبایل، تنظیمات اعلان، کد ارجاع و رابطه والد/فرزند. ارتباط یکبهچند با آدرسها، نقشها، سفارشها، قراردادها، کیف پول و سبد خرید.
|
||||
- **Role / UserRole**: تعریف نقشهای سیستمی و نگاشت چند-به-چند کاربر به نقش. جهت کنترل دسترسی BackOffice.
|
||||
- **OtpToken**: ذخیره توکنهای OTP با هش کد، هدف (Purpose)، زمان انقضا، تعداد تلاش و وضعیت مصرف برای جریان لاگین/ثبتنام.
|
||||
|
||||
### لایه محتوا و کاتالوگ محصول
|
||||
- **Category**: ساختار درختی دستهبندی با عنوان، توضیحات، تصویر، ترتیب نمایش و وضعیت فعال بودن. `ParentId` برای تو در تویی و ارتباط با `PruductCategory`.
|
||||
- **Tag / PruductTag**: برچسبهای قابل جستجو برای محصولات با وضعیت فعال و ترتیب. جدول واسط `PruductTag` اتصال چند-به-چند محصول و تگ را نگه میدارد.
|
||||
- **Products**: جزئیات کامل محصول شامل توضیحات کوتاه/طولانی، قیمت، تخفیف، نرخ، تصاویر اصلی/Thumbnail، آمار فروش و موجودی. ارتباط با سبد، گالری، فاکتور، دسته و تگ.
|
||||
- **ProductImages / ProductGallerys**: مدیریت داراییهای تصویری. `ProductImages` مشخصات فایل را نگه میدارد و `ProductGallerys` رابطه هر تصویر با یک محصول را ثبت میکند تا چیدمان گالری قابل کنترل باشد.
|
||||
- **Package**: باندل یا سرویس قابل فروش با عنوان، توضیح، تصویر و قیمت ثابت که میتواند داخل سفارش کاربر قرار گیرد.
|
||||
- **Category–Product Pivot (`PruductCategory`)**: ردیفهای عضویت محصول در دستههای متعدد. هر ردیف شامل `ProductId` و `CategoryId` است.
|
||||
|
||||
### لایه سفارش و تراکنش
|
||||
- **UserCarts**: آیتمهای سبد خرید کاربر، شامل شناسه محصول، کاربر و تعداد. منبع اصلی عملیات افزودن/حذف سبد در FrontOffice.
|
||||
- **UserAddress**: آدرسهای پستی کاربران با عنوان، متن آدرس، کد پستی، شهر، وضعیت پیشفرض و ارتباط با سفارشها.
|
||||
- **UserOrder**: سفارش نهایی شامل مبلغ، ارجاع به پکیج/تراکنش، وضعیت و تاریخ پرداخت، روش پرداخت، وضعیت ارسال، کد رهگیری و توضیحات ارسال. همچنین به آدرس کاربر و آیتمهای فاکتور (`FactorDetails`) متصل است.
|
||||
- **FactorDetails**: اقلام درون سفارش؛ هر ردیف به محصول و سفارش اشاره دارد و تعداد، قیمت واحد، تخفیف و وضعیت تغییر قیمت را نگه میدارد.
|
||||
- **Transactions**: لاگ مالی سطح درگاه با مبلغ، توضیح، وضعیت/تاریخ پرداخت، شناسه مرجع درگاه و نوع تراکنش (Persistent در Enum `TransactionType`). سفارشها میتوانند به یک تراکنش اشاره کنند.
|
||||
|
||||
### لایه کیف پول و تسویه
|
||||
- **UserWallet**: کیف پول ریالی/شبکهای هر کاربر با موجودی جاری و موجودی شبکه (`NetworkBalance`).
|
||||
- **UserWalletChangeLog**: ژورنال تغییرات کیف پول شامل موجودی قبل/بعد، مقدار تغییر، تغییر شبکه، اینکه افزایش یا کاهش بوده و شناسه مرجع (مثلاً تراکنش یا سفارش). ستون `Created` منبع اصلی timestamp فاکتور کیف پول است.
|
||||
|
||||
### لایه قرارداد و رعایت الزامات
|
||||
- **Contract**: قالب قراردادها با عنوان، توضیحات، متن HTML و نوع قرارداد (`ContractType`).
|
||||
- **UserContract**: سوابق موافقت کاربر با قراردادها، شامل فایل PDF امضا شده و `SignGuid` برای ردیابی امضا.
|
||||
|
||||
## ماژولها و بیزینس مفصل
|
||||
### کاربران و هویت
|
||||
- **ثبتنام**: با دریافت موبایل، رکورد `User` ساخته و OTP برای تایید ارسال میشود. شرط یکتایی موبایل در سطح پایگاه داده enforced است و در Handler نیز بررسی میشود.
|
||||
- **تکمیل پروفایل**: کاربر میتواند نام، کد ملی، تاریخ تولد و تنظیمات اعلان را تکمیل کند. فعالسازی اعلانها به BFF اطلاع میدهد تا Subscription در سرویس پوش ثبت شود.
|
||||
- **مدیریت نقش**: Admin میتواند از API `UserRoleCQ` برای افزودن نقش جدید استفاده کند؛ در صورت حذف نقش، ابتدا باید عضویتهای فعال کاربر قطع شود.
|
||||
|
||||
### کاتالوگ و محتوا
|
||||
- **دستهبندی درختی**: سطح بینهایت تو در تو پشتیبانی میشود. حذف یک دسته زمانی مجاز است که هیچ `Categorys` فرزند و هیچ `PruductCategory` فعالی نداشته باشد؛ در غیر این صورت باید انتقال انجام شود.
|
||||
- **چرخه محصول**: ایجاد محصول شامل ثبت داده متنی، بارگذاری تصویر شاخص، تعریف قیمت و تعیین تخفیف است. تغییر قیمت در Handler ثبت شده و قوانین جلوگیری از عدد منفی یا Discount بزرگتر از 100٪ اعمال میشود.
|
||||
- **گالری و تصاویر**: ابتدا تصویر در `ProductImages` ثبت و سپس با `ProductGallerys` به محصول متصل میشود تا یک تصویر بتواند در چند محصول استفاده شود. حذف تصویر اگر در گالری فعال باشد ممنوع است.
|
||||
- **پکیجها**: برای فروش سرویس اشتراکی یا باندل؛ فیلد `Price` مبنای محاسبه سفارشهای نوع Package است و تغییر قیمت روی سفارشهای ثبتشده تاثیر ندارد زیرا مبلغ در `UserOrder.Amount` ذخیره میشود.
|
||||
|
||||
### سفارش، پرداخت و لجستیک
|
||||
- **سبد خرید**: عملیات Add/Update/Delete روی `UserCarts` انجام میشود. در هر لحظه برای ترکیب (User, Product) تنها یک رکورد وجود دارد. اگر Count صفر شود، رکورد حذف منطقی میشود تا تاریخچه حفظ گردد.
|
||||
- **Checkout**: Handler `SubmitShopBuyOrder` اقلام سبد را قفل خوشبینانه کرده، سفارش (`UserOrder`) و اقلام فاکتور (`FactorDetails`) را میسازد، آدرس پیشفرض را نگاشت و وضعیت پرداخت را Pending میگذارد.
|
||||
- **پرداخت آنلاین**: پس از هدایت به درگاه، سیستم CallBack در `TransactionsCQ` را دریافت میکند؛ شناسه مرجع (`RefId`) و مبلغ تطبیق داده میشود. در صورت موفقیت، `PaymentStatus` سفارش و تراکنش Success شده و `PaymentDate` ذخیره میشود. در صورت Reject، سبد به حالت قبل بازگردانده میشود.
|
||||
- **پرداخت با کیف پول**: اگر موجودی کافی باشد، به صورت اتمیک از کیف پول کسر و سفارش Success میشود؛ نیازی به تراکنش درگاه نیست.
|
||||
- **لجستیک**: فیلدهای `DeliveryStatus`, `TrackingCode`, `DeliveryDescription` وضعیت ارسال را پوشش میدهند. هر تغییر وضعیت میتواند Notification برای کاربر یا تیم پشتیبانی ایجاد کند.
|
||||
|
||||
### کیف پول و تسویه داخلی
|
||||
- **ساخت کیف پول**: همزمان با ثبتنام یا اولین تراکنش، رکورد `UserWallet` ساخته میشود. موجودی شبکه برای پشتیبانی از داراییهای خارج از پلتفرم است.
|
||||
- **ChangeLog**: هر تغییر موجودی همراه با مقدار قبل/بعد، مقدار شبکه، نوع عملیات (Increase/Decrease) و `ReferenceId` ثبت میشود تا audit کافی فراهم گردد. Handler ها Idempotency را با بررسی ReferenceId رعایت میکنند.
|
||||
- **واریز**: میتواند از طریق درگاه آنلاین یا عملیات دستی ادمین باشد. پس از تایید بانک، مبلغ به `Balance` افزوده و ChangeLog با نوع Deposit ذخیره میشود.
|
||||
- **برداشت/تسویه**: درخواست Withdrawal ابتدا به صف تایید دستی میرود (Business Rule). پس از تایید، مبلغ از `Balance` کم و اگر نیاز به ارسال به شبکه بلاکچین باشد، `NetworkBalance` نیز بهروزرسانی میشود.
|
||||
- **بازپرداخت سفارش**: در صورت لغو سفارش پرداختشده، مقدار پرداختی با ChangeLog نوع Refund به کیف پول برمیگردد تا کاربر بتواند مجدد خرید کند یا برداشت انجام دهد.
|
||||
|
||||
### قرارداد و انطباق
|
||||
- **مدیریت نسخه**: هر بار که متن قرارداد تغییر کند، رکورد جدیدی در `Contract` ساخته میشود. `UserContract` با نگه داشتن `ContractId` مشخص میکند کاربر کدام نسخه را امضا کرده است.
|
||||
- **فرآیند امضا**: برای امضای دیجیتال، سیستم `SignGuid` را به سرویس امضای بیرونی ارسال میکند. پس از تکمیل، فایل PDF در فضای ذخیرهسازی آپلود و مسیر آن در `UserContract.SignedPdfFile` ثبت میشود.
|
||||
- **کنترل پذیرش قوانین**: فیلدهای `IsRulesAccepted` و `RulesAcceptedAt` در موجودیت User نیز نگهداری میشوند تا بتوان دفعات قبول قوانین عمومی را از قراردادهای اختصاصی تفکیک کرد.
|
||||
|
||||
### گزارش و مانیتورینگ
|
||||
- تمام Queries دارای پارامترهای Paging و Sorting هستند تا BackOffice بتواند داشبورد مدیریتی بسازد.
|
||||
- به کمک Mapster Projection فقط ستونهای مورد نیاز خوانده میشود؛ در موارد خاص (مثل تاریخ تراکنش کیف پول) Projection دستی به DTO اعمال شده است.
|
||||
- ساختار CQRS اجازه میدهد که در آینده Event Handler یا Outbox برای همگامسازی با سرویسهای دیگر اضافه شود.
|
||||
|
||||
## فرایندهای بیزینسی کلیدی
|
||||
### 1. احراز هویت و ورود
|
||||
1. کاربر شماره موبایل را ارسال میکند؛ `OtpTokenCQ` یک رکورد جدید با کد هششده، زمان انقضا و شمارش تلاشها میسازد. درصورت وجود رکورد فعال، ابتدا Attempts چک و درصورت عبور از سقف، خطای تجاری برگردانده میشود.
|
||||
2. کاربر کد را ارسال میکند؛ سیستم hash تولید میکند و با `CodeHash` مقایسه میشود. در صورت موفقیت، `IsUsed` و `IsMobileVerified` تنظیم میشوند و تاریخ تایید موبایل ذخیره میگردد.
|
||||
3. اگر کاربر برای اولینبار وارد شود، کیف پول و Role پیشفرض ایجاد میشود. سپس سرویس JWT توکن امضا شده (همراه با Claims نقشها) را برمیگرداند.
|
||||
|
||||
### 2. مدیریت کاتالوگ و محتوای فروش
|
||||
- اپراتور BackOffice از طریق دستهها، تگها و محصولات API های `CategoryCQ`, `ProductsCQ`, `TagCQ` و … اقلام را CRUD میکند.
|
||||
- تصاویر از طریق `ProductImagesCQ` ثبت و سپس با `ProductGallerysCQ` به محصولات لینک میشوند تا ترتیب نمایش قابل تغییر باشد.
|
||||
- باندلهای اشتراکی یا خدمات از طریق `PackageCQ` تعریف میشوند و در سفارشها استفاده میشوند.
|
||||
- قوانین کیفیت داده: عنوان و توضیح محصول نمیتواند خالی باشد، تصویر شاخص باید پیش از انتشار محصول مشخص شود و حداقل یک دسته فعال برای محصول الزامی است.
|
||||
- وضعیت فعال/غیرفعال دستهها در API لیست محصولات اعمال میشود تا محصولات دسته غیرفعال نمایش داده نشوند.
|
||||
|
||||
### 3. تجربه خرید (Cart → Order → Transaction)
|
||||
1. FrontOffice اقلام را در `UserCarts` ثبت/ویرایش میکند.
|
||||
2. هنگام تسویه، Handler های `UserOrderCQ` سفارش و اقلام `FactorDetails` را میسازند، آدرس پیشفرض UserAddress را ضمیمه میکنند و وضعیت پرداخت را `Pending` قرار میدهند.
|
||||
3. پس از موفقیت درگاه، سرویس تراکنش (`TransactionsCQ`) شناسه مرجع را ذخیره و `PaymentStatus` سفارش و تراکنش را `Success` میکند؛ تاریخ پرداخت نیز ست میشود.
|
||||
4. وضعیت ارسال (`DeliveryStatus`) در طول فرایند Fulfillment آپدیت شده و کد رهگیری پستی داخل سفارش نگهداری میشود.
|
||||
- سناریو شکست درگاه: اگر درگاه خطا دهد، سفارش در حالت Pending باقی میماند و Job زمانبندی شده این سفارشها را بعد از زمان مشخص لغو میکند تا سبد دوباره آزاد شود.
|
||||
- امکان پرداخت ترکیبی (کیف پول + درگاه) وجود دارد؛ ابتدا از کیف پول برداشت و سپس باقیمانده به درگاه ارسال میشود.
|
||||
|
||||
### 4. کیف پول و صورتحساب داخلی
|
||||
- هر کاربر دقیقا یک کیف پول فعال دارد (`UserWalletCQ`).
|
||||
- واریز/برداشت (چه ناشی از پرداخت آنلاین چه عملیات دستی) همیشه یک رکورد در `UserWalletChangeLog` ایجاد میکند تا موجودی قبلی، مقدار تغییر و منبع (ReferenceId) مشخص باشد.
|
||||
- FrontOffice برای نمایش تاریخ دقیق تراکنشها از `Created` لاگ استفاده میکند؛ بنابراین Handler های `UserWalletChangeLogCQ` حتما `CreatedAt` را به DTO و gRPC پاسخ اضافه میکنند.
|
||||
- ChangeLog ها قابلیت فیلتر بر اساس نوع عملیات، بازه تاریخی و ReferenceId دارند و مقادیر در DTO به timestamp یونیکس هم تبدیل میشود تا فرانت به راحتی فرمت کند.
|
||||
- عملیات دستی ادمین حتما توضیح (Description) و شناسه اپراتور را ثبت میکند تا audit کامل باشد.
|
||||
|
||||
### 5. قراردادها و انطباق
|
||||
- محتوای قرارداد (Term of Service، قرارداد نمایندگی و …) در `Contract` نگهداری میشود.
|
||||
- هنگام امضا، یک `UserContract` شامل فایل PDF امضا شده و `SignGuid` ایجاد میگردد تا سوابق حقوقی نگهداری شود. این اطلاعات در درخواستهای بعدی احراز میشوند تا از کاربران فقط یکبار امضا گرفته شود.
|
||||
- در صورت بهروزرسانی متن قرارداد، کاربران باید مجدداً آن را تایید کنند؛ FrontOffice هنگام ورود این شرط را بررسی و کاربر را به صفحه امضا هدایت میکند.
|
||||
- سیستم گزارش میدهد چه تعداد کاربر هر نسخه را امضا کردهاند تا تیم حقوقی مطمئن شود پوشش قانونی کامل است.
|
||||
|
||||
## نکات پیادهسازی و توسعه
|
||||
- **CQRS پوشهبندی**: هر ماژول (مثلاً `UserWalletCQ`) شامل زیرپوشههای Commands و Queries است. درخواستهای gRPC از پروژه Protobuf با DTO های Application نگاشت میشوند.
|
||||
- **همگامسازی قراردادها**: هر زمان فیلد جدیدی به موجودیت اضافه شود باید DTO، Handler و قرارداد Protobuf متناظر نیز بهروزرسانی و `dotnet build` برای تولید مجدد stubs اجرا شود. سپس BFF ها باید پکیج جدید را دریافت کنند.
|
||||
- **اتصال با BFF**: CMS WebApi سرویسهای gRPC را در پورت تعریف شده در `appsettings` اکسپوز میکند. BFF ها با استفاده از Channel مطمئن (TLS داخلی) به آن متصل میشوند و Mapster را برای تبدیل به مدلهای فرانت استفاده میکنند.
|
||||
- **Dependency Injection**: تمام Handler ها و سرویسها در `CMSMicroservice.Application/ConfigureServices.cs` و `CMSMicroservice.Infrastructure/ConfigureServices.cs` ثبت میشوند تا تستپذیری افزایش یابد.
|
||||
- **اعتبارسنجی و لاگ**: Behaviour های مشترک (LoggingBehaviour, ValidationBehaviour) روی Pipeline MediatR نشستهاند تا قبل از اجرای Handler، ورودیها چک و لاگ ساختارمند تولید شود.
|
||||
- **زمانبندی تمیزکاری**: ستون `IsDeleted` برای Soft Delete بهکار میرود. Handler هایی که لیست میدهند معمولا فیلتر `!IsDeleted` را اعمال میکنند؛ برای نمایش آرشیو باید صراحتاً flag درخواست شود.
|
||||
- **Enums مهم**: `PaymentStatus`, `PaymentMethod`, `DeliveryStatus`, `ContractType`, `TransactionType` طیف وضعیتهای مالی/قراردادی را استاندارد میکنند و باید بین FrontOffice و BackOffice همسو نگه داشته شوند.
|
||||
- **آیتمهای Idempotent**: عملیات حساس مثل واریز کیف پول یا ثبت سفارش از ReferenceId استفاده میکنند تا در تکرار درخواستها نتیجهی تکراری ایجاد نشود.
|
||||
|
||||
## مسیرهای مرتبط
|
||||
- ساختار کد: `CMS/src/CMSMicroservice.Domain/Entities`, `CMSMicroservice.Application/*CQ`, `CMSMicroservice.Protobuf/Protos`.
|
||||
- مستند حاضر: `CMS/docs/cms-data-and-business.md`
|
||||
- نقاط تماس بیرونی: gRPC Endpoint های `CMSMicroservice.WebApi` به صورت داخلی مصرف میشوند و از طریق FrontOffice/BackOffice BFF در اختیار UI قرار میگیرند.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
||||
# 🔄 Migration Guide: ParentId → NetworkParentId
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
در سیستم قدیمی، کاربران با استفاده از `User.ParentId` به هم متصل میشدند (Parent-Child relationship).
|
||||
سیستم جدید **Network-Club-Commission** از یک **Binary Tree** استفاده میکند که نیاز به:
|
||||
- `User.NetworkParentId` (شناسه پدر در شبکه باینری)
|
||||
- `User.LegPosition` (Left یا Right)
|
||||
|
||||
برای اجرای صحیح Worker و محاسبات، **باید** تمام کاربران قدیمی Migrate شوند.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Critical Issues
|
||||
|
||||
### مشکل 1: Binary Tree Constraint
|
||||
- هر Parent فقط میتواند **2 فرزند** داشته باشد (Left & Right)
|
||||
- اگر کاربری در سیستم قدیمی بیشتر از 2 فرزند دارد، Migration فقط **2 فرزند اول** را میگیرد
|
||||
|
||||
### مشکل 2: Orphaned Nodes
|
||||
- اگر `ParentId` اشاره به یک کاربر نامعتبر (حذف شده) باشد، آن User **Orphaned** است
|
||||
- Orphaned nodes در Binary Tree نادیده گرفته میشوند
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Migration Methods
|
||||
|
||||
### روش 1: Automatic (Seeder - توصیه میشود)
|
||||
|
||||
Migration به صورت خودکار در `Program.cs` در حالت **Development** اجرا میشود:
|
||||
|
||||
```csharp
|
||||
// در Program.cs
|
||||
var migrationSeeder = new NetworkParentIdMigrationSeeder(dbContext, logger);
|
||||
await migrationSeeder.SeedAsync();
|
||||
```
|
||||
|
||||
**مزایا:**
|
||||
- ✅ Idempotent (میتوان چندین بار اجرا کرد، فقط یکبار تاثیر میگذارد)
|
||||
- ✅ Validation اتوماتیک
|
||||
- ✅ Logging کامل
|
||||
|
||||
**کجا اجرا میشود؟**
|
||||
- فقط در **Development** environment
|
||||
- هر بار که پروژه Run شود
|
||||
|
||||
---
|
||||
|
||||
### روش 2: Manual (Command)
|
||||
|
||||
اگر نیاز به اجرای دستی دارید:
|
||||
|
||||
```csharp
|
||||
// درخواست از طریق MediatR
|
||||
var result = await _mediator.Send(new MigrateNetworkParentIdCommand());
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
Console.WriteLine($"Migrated: {result.MigratedCount}");
|
||||
Console.WriteLine($"Skipped: {result.SkippedCount}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Error: {result.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### روش 3: SQL Script
|
||||
|
||||
برای Production یا اجرای مستقیم روی Database:
|
||||
|
||||
```bash
|
||||
# فایل: CMSMicroservice.Infrastructure/Migrations/Scripts/20250601_MigrateParentIdToNetworkParentId.sql
|
||||
```
|
||||
|
||||
**نکته مهم:**
|
||||
قبل از اجرا، **حتماً** بررسی کنید که آیا کاربری بیش از 2 فرزند دارد:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
ParentId,
|
||||
COUNT(*) as ChildCount,
|
||||
STRING_AGG(CAST(Id AS VARCHAR), ', ') as ChildIds
|
||||
FROM Users
|
||||
WHERE ParentId IS NOT NULL
|
||||
GROUP BY ParentId
|
||||
HAVING COUNT(*) > 2;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Validation After Migration
|
||||
|
||||
### 1. بررسی تعداد کاربران Migrate شده
|
||||
|
||||
```csharp
|
||||
var stats = await _context.Users
|
||||
.GroupBy(u => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
TotalUsers = g.Count(),
|
||||
UsersWithNetworkParent = g.Count(u => u.NetworkParentId != null),
|
||||
LeftChildren = g.Count(u => u.LegPosition == NetworkLeg.Left),
|
||||
RightChildren = g.Count(u => u.LegPosition == NetworkLeg.Right)
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
```
|
||||
|
||||
### 2. بررسی Orphaned Nodes
|
||||
|
||||
```sql
|
||||
SELECT Id, NetworkParentId
|
||||
FROM Users
|
||||
WHERE NetworkParentId IS NOT NULL
|
||||
AND NetworkParentId NOT IN (SELECT Id FROM Users);
|
||||
```
|
||||
|
||||
### 3. بررسی Binary Tree Violation
|
||||
|
||||
```sql
|
||||
SELECT NetworkParentId, COUNT(*) as ChildCount
|
||||
FROM Users
|
||||
WHERE NetworkParentId IS NOT NULL
|
||||
GROUP BY NetworkParentId
|
||||
HAVING COUNT(*) > 2;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Algorithm Details
|
||||
|
||||
### مراحل Migration:
|
||||
|
||||
1. **Find Users**: یافتن کاربران با `ParentId != NULL` و `NetworkParentId == NULL`
|
||||
2. **Group by Parent**: گروهبندی بر اساس ParentId
|
||||
3. **Check Constraint**: اگر Parent بیش از 2 فرزند دارد، فقط 2 تا اول را بگیر
|
||||
4. **Assign Values**:
|
||||
```csharp
|
||||
child.NetworkParentId = parentId;
|
||||
child.LegPosition = (i == 0) ? NetworkLeg.Left : NetworkLeg.Right;
|
||||
```
|
||||
5. **Save & Validate**: ذخیره و اعتبارسنجی Binary Tree
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### مشکل: Parent has more than 2 children
|
||||
|
||||
**راه حل:**
|
||||
تصمیم دستی بگیرید که کدام 2 فرزند را نگه دارید:
|
||||
|
||||
```sql
|
||||
-- بررسی کنید که کدام Parent مشکل دارد
|
||||
SELECT ParentId, COUNT(*) as ChildCount
|
||||
FROM Users
|
||||
WHERE ParentId = 123
|
||||
GROUP BY ParentId;
|
||||
|
||||
-- لیست فرزندان را ببینید
|
||||
SELECT Id, FullName, CreatedAt
|
||||
FROM Users
|
||||
WHERE ParentId = 123
|
||||
ORDER BY CreatedAt;
|
||||
|
||||
-- دستی NetworkParentId را برای 2 فرزند انتخابی Set کنید
|
||||
UPDATE Users
|
||||
SET NetworkParentId = 123, LegPosition = 0 -- Left
|
||||
WHERE Id = 456;
|
||||
|
||||
UPDATE Users
|
||||
SET NetworkParentId = 123, LegPosition = 1 -- Right
|
||||
WHERE Id = 789;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### مشکل: Orphaned Nodes (Parent doesn't exist)
|
||||
|
||||
**راه حل:**
|
||||
ParentId را NULL کنید یا به یک Parent معتبر متصل کنید:
|
||||
|
||||
```sql
|
||||
-- گزینه 1: NULL کردن (Root شدن)
|
||||
UPDATE Users
|
||||
SET ParentId = NULL, NetworkParentId = NULL
|
||||
WHERE ParentId = 999; -- 999 وجود ندارد
|
||||
|
||||
-- گزینه 2: اتصال به Parent دیگر
|
||||
UPDATE Users
|
||||
SET ParentId = 1, NetworkParentId = 1
|
||||
WHERE ParentId = 999;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist Before Production
|
||||
|
||||
- [ ] Migration در Development اجرا شده؟
|
||||
- [ ] Validation Errors بررسی شد؟
|
||||
- [ ] Orphaned Nodes رفع شدند؟
|
||||
- [ ] Binary Tree Violations رفع شدند؟
|
||||
- [ ] Backup از Database گرفته شده؟
|
||||
- [ ] Migration Script برای Production آماده است؟
|
||||
- [ ] Testing کامل انجام شده؟
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
- **Seeder**: `CMSMicroservice.Infrastructure/Data/Seeding/NetworkParentIdMigrationSeeder.cs`
|
||||
- **Command**: `CMSMicroservice.Application/UserCQ/Commands/MigrateNetworkParentId/`
|
||||
- **SQL Script**: `CMSMicroservice.Infrastructure/Migrations/Scripts/20250601_MigrateParentIdToNetworkParentId.sql`
|
||||
- **Entity**: `CMSMicroservice.Domain/Entities/User.cs` (خطوط 16, 45, 49)
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
اگر مشکل خاصی با Migration پیدا کردید:
|
||||
1. Log های Seeder را بررسی کنید
|
||||
2. ValidationErrors را چک کنید
|
||||
3. SQL Script را به صورت دستی اجرا کنید
|
||||
@@ -1,732 +0,0 @@
|
||||
# 📊 Monitoring & Alerts System - Consolidated Implementation Report
|
||||
|
||||
**Date**: 2025-11-30
|
||||
**Status**: ✅ Skeleton Implemented (30% Complete)
|
||||
**Build**: ✅ Success
|
||||
|
||||
---
|
||||
|
||||
## 📋 Executive Summary
|
||||
|
||||
اسکلت کامل سیستم Monitoring & Alerts پیادهسازی شد. این سیستم شامل دو بخش اصلی است:
|
||||
1. **Alert System**: اعلانهای مدیریتی (Critical/Warning/Success) برای Admin
|
||||
2. **User Notification System**: اعلانهای کاربری (SMS/Email/Push) برای Users
|
||||
|
||||
فعلاً فقط Logging فعال است. Integration های اصلی (Sentry, Slack, SMS) آماده پیادهسازی هستند.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Application Layer │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ IAlertService │ │ IUserNotificationService│ │
|
||||
│ │ - Critical │ │ - Commission Received │ │
|
||||
│ │ - Warning │ │ - Club Activation │ │
|
||||
│ │ - Success │ │ - Payout Error │ │
|
||||
│ └─────────────────────┘ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ implements
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Infrastructure Layer │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ AlertService │ │ UserNotificationService │ │
|
||||
│ │ ✅ Logging │ │ ✅ Logging │ │
|
||||
│ │ ⏳ Sentry │ │ ⏳ SMS Gateway │ │
|
||||
│ │ ⏳ Slack │ │ ⏳ Email Service │ │
|
||||
│ │ ⏳ Email │ │ ⏳ Push Notification │ │
|
||||
│ └─────────────────────┘ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ MonitoringSettings (Configuration) │ │
|
||||
│ │ - SentryEnabled, SentryDsn │ │
|
||||
│ │ - SlackEnabled, SlackWebhookUrl │ │
|
||||
│ │ - EmailAlertsEnabled, AdminEmails │ │
|
||||
│ │ - SmsNotificationsEnabled, SmsApiKey │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ used by
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Background Workers / Handlers │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ WeeklyNetworkCommissionWorker │ │
|
||||
│ │ - On Success: SendSuccessNotificationAsync() │ │
|
||||
│ │ - On Error: SendCriticalAlertAsync() │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ ProcessUserPayoutsCommandHandler │ │
|
||||
│ │ - On Payout: SendCommissionReceivedNotification│ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Implementation Details
|
||||
|
||||
### 1️⃣ Alert Service (Admin Notifications)
|
||||
|
||||
**Interface**: `CMSMicroservice.Application/Common/Interfaces/IAlertService.cs`
|
||||
|
||||
```csharp
|
||||
public interface IAlertService
|
||||
{
|
||||
Task SendCriticalAlertAsync(string title, string message, Exception? exception, CancellationToken ct);
|
||||
Task SendWarningAlertAsync(string title, string message, CancellationToken ct);
|
||||
Task SendSuccessNotificationAsync(string title, string message, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation**: `CMSMicroservice.Infrastructure/Services/Monitoring/AlertService.cs`
|
||||
|
||||
**Current Behavior**:
|
||||
```
|
||||
🚨 CRITICAL ALERT: {Title} - {Message}
|
||||
⚠️ WARNING ALERT: {Title} - {Message}
|
||||
✅ SUCCESS: {Title} - {Message}
|
||||
```
|
||||
|
||||
**Pending Integrations**:
|
||||
- **Sentry**: Exception tracking & aggregation (TODO: `SentrySdk.CaptureException()`)
|
||||
- **Slack**: Real-time alerts to channel (TODO: HTTP POST to webhook)
|
||||
- **Email**: Alert emails to admin list (TODO: SMTP integration)
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ User Notification Service
|
||||
|
||||
**Interface**: `CMSMicroservice.Application/Common/Interfaces/IAlertService.cs` (same file)
|
||||
|
||||
```csharp
|
||||
public interface IUserNotificationService
|
||||
{
|
||||
Task SendCommissionReceivedNotificationAsync(long userId, decimal amount, int weekNumber, CancellationToken ct);
|
||||
Task SendClubActivationNotificationAsync(long userId, CancellationToken ct);
|
||||
Task SendPayoutErrorNotificationAsync(long userId, string errorMessage, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation**: `CMSMicroservice.Infrastructure/Services/Monitoring/UserNotificationService.cs`
|
||||
|
||||
**Current Behavior**:
|
||||
```
|
||||
📧 Sending commission notification: User={UserId}, Amount={Amount}, Week={WeekNumber}
|
||||
🎉 Sending club activation notification: User={UserId}
|
||||
⚠️ Sending payout error notification: User={UserId}, Error={Error}
|
||||
```
|
||||
|
||||
**Pending Integrations**:
|
||||
- **SMS Gateway**: Kavenegar/Ghasedak integration (TODO: HTTP API call)
|
||||
- **Email Service**: SMTP/SendGrid integration (TODO: template-based emails)
|
||||
- **Push Notification**: FCM/OneSignal integration (TODO: mobile app notifications)
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Configuration Model
|
||||
|
||||
**File**: `CMSMicroservice.Infrastructure/Services/Monitoring/MonitoringSettings.cs`
|
||||
|
||||
```csharp
|
||||
public class MonitoringSettings
|
||||
{
|
||||
public const string SectionName = "Monitoring";
|
||||
|
||||
// Sentry
|
||||
public bool SentryEnabled { get; set; }
|
||||
public string? SentryDsn { get; set; }
|
||||
|
||||
// Slack
|
||||
public bool SlackEnabled { get; set; }
|
||||
public string? SlackWebhookUrl { get; set; }
|
||||
|
||||
// Email Alerts (Admin)
|
||||
public bool EmailAlertsEnabled { get; set; }
|
||||
public List<string> AdminEmails { get; set; }
|
||||
|
||||
// SMS (User Notifications)
|
||||
public bool SmsNotificationsEnabled { get; set; }
|
||||
public string? SmsApiKey { get; set; }
|
||||
public string? SmsGatewayUrl { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Config File**: `CMSMicroservice.WebApi/appsettings.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"Monitoring": {
|
||||
"SentryEnabled": false,
|
||||
"SentryDsn": "",
|
||||
"SlackEnabled": false,
|
||||
"SlackWebhookUrl": "",
|
||||
"EmailAlertsEnabled": false,
|
||||
"AdminEmails": ["admin@example.com"],
|
||||
"SmsNotificationsEnabled": false,
|
||||
"SmsApiKey": "",
|
||||
"SmsGatewayUrl": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ Dependency Injection
|
||||
|
||||
**File**: `CMSMicroservice.Infrastructure/ConfigureServices.cs`
|
||||
|
||||
```csharp
|
||||
services.AddScoped<IAlertService, AlertService>();
|
||||
services.AddScoped<IUserNotificationService, UserNotificationService>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ Worker Integration
|
||||
|
||||
**File**: `CMSMicroservice.Infrastructure/BackgroundJobs/WeeklyNetworkCommissionWorker.cs`
|
||||
|
||||
**On Success**:
|
||||
```csharp
|
||||
await alertService.SendSuccessNotificationAsync(
|
||||
"Weekly Commission Completed",
|
||||
$"Week {previousWeekNumber}: {payoutsProcessed} payouts, {balancesToExpire.Count} balances expired");
|
||||
```
|
||||
|
||||
**On Error**:
|
||||
```csharp
|
||||
await alertService.SendCriticalAlertAsync(
|
||||
"Weekly Commission Worker Failed",
|
||||
$"Worker execution {executionId} failed for week {GetPreviousWeekNumber()}",
|
||||
ex,
|
||||
cancellationToken);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Integration Roadmap
|
||||
|
||||
### Priority 1: Sentry (High - 1 hour)
|
||||
|
||||
**Why**: Critical error tracking & aggregation برای Production
|
||||
|
||||
**Steps**:
|
||||
1. Install NuGet:
|
||||
```bash
|
||||
dotnet add package Sentry.AspNetCore
|
||||
```
|
||||
|
||||
2. Configure in `Program.cs`:
|
||||
```csharp
|
||||
builder.WebHost.UseSentry(options =>
|
||||
{
|
||||
options.Dsn = builder.Configuration["Monitoring:SentryDsn"];
|
||||
options.Environment = builder.Environment.EnvironmentName;
|
||||
options.TracesSampleRate = 1.0;
|
||||
});
|
||||
```
|
||||
|
||||
3. Update `AlertService.SendCriticalAlertAsync()`:
|
||||
```csharp
|
||||
if (_settings.SentryEnabled && exception != null)
|
||||
{
|
||||
SentrySdk.CaptureException(exception, scope =>
|
||||
{
|
||||
scope.SetTag("alert.title", title);
|
||||
scope.SetExtra("message", message);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
4. Set DSN in `appsettings.Production.json`:
|
||||
```json
|
||||
{
|
||||
"Monitoring": {
|
||||
"SentryEnabled": true,
|
||||
"SentryDsn": "https://xxxxx@sentry.io/12345"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Priority 2: Slack Webhook (Medium - 2 hours)
|
||||
|
||||
**Why**: Real-time alerts به تیم Development/DevOps
|
||||
|
||||
**Steps**:
|
||||
1. Create Incoming Webhook در Slack:
|
||||
- Go to: `https://api.slack.com/apps`
|
||||
- Create app → Incoming Webhooks → Add to channel
|
||||
- Copy Webhook URL
|
||||
|
||||
2. Update `AlertService`:
|
||||
```csharp
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public async Task SendCriticalAlertAsync(...)
|
||||
{
|
||||
_logger.LogCritical(exception, "🚨 {Title} - {Message}", title, message);
|
||||
|
||||
if (_settings.SlackEnabled)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
text = $"🚨 *{title}*",
|
||||
attachments = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
color = "danger",
|
||||
text = message,
|
||||
fields = exception != null ? new[]
|
||||
{
|
||||
new { title = "Exception", value = exception.Message, @short = false }
|
||||
} : null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await _httpClient.PostAsJsonAsync(_settings.SlackWebhookUrl, payload);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Set Webhook URL in config:
|
||||
```json
|
||||
{
|
||||
"Monitoring": {
|
||||
"SlackEnabled": true,
|
||||
"SlackWebhookUrl": "https://hooks.slack.com/services/T00/B00/XXX"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Priority 3: SMS Gateway - Kavenegar (Medium - 3 hours)
|
||||
|
||||
**Why**: اطلاعرسانی کمیسیون به کاربران
|
||||
|
||||
**Steps**:
|
||||
1. Get API Key from Kavenegar:
|
||||
- Sign up: `https://panel.kavenegar.com`
|
||||
- API Key: Settings → API Key
|
||||
|
||||
2. Create `ISmsGatewayService`:
|
||||
```csharp
|
||||
public interface ISmsGatewayService
|
||||
{
|
||||
Task SendAsync(string mobile, string message, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
3. Implement `KavenegarSmsService`:
|
||||
```csharp
|
||||
public class KavenegarSmsService : ISmsGatewayService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _apiKey;
|
||||
|
||||
public async Task SendAsync(string mobile, string message, CancellationToken ct)
|
||||
{
|
||||
var url = $"https://api.kavenegar.com/v1/{_apiKey}/sms/send.json";
|
||||
var payload = new
|
||||
{
|
||||
receptor = mobile,
|
||||
message = message
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, payload, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Update `UserNotificationService.SendCommissionReceivedNotificationAsync()`:
|
||||
```csharp
|
||||
var user = await _context.Users.FindAsync(userId, ct);
|
||||
|
||||
if (user.SmsNotifications && _settings.SmsNotificationsEnabled)
|
||||
{
|
||||
var message = $"کمیسیون شما: {amount:N0} ریال برای هفته {weekNumber} واریز شد.";
|
||||
await _smsGateway.SendAsync(user.Mobile, message, ct);
|
||||
}
|
||||
```
|
||||
|
||||
5. Configure:
|
||||
```json
|
||||
{
|
||||
"Monitoring": {
|
||||
"SmsNotificationsEnabled": true,
|
||||
"SmsApiKey": "your-kavenegar-api-key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Priority 4: Email Alerts for Admins (Low - 2 hours)
|
||||
|
||||
**Why**: Backup notification channel
|
||||
|
||||
**Options**:
|
||||
- **A) MailKit (SMTP)**:
|
||||
```csharp
|
||||
using var client = new SmtpClient();
|
||||
await client.ConnectAsync("smtp.gmail.com", 587, SecureSocketOptions.StartTls);
|
||||
await client.AuthenticateAsync("user@example.com", "password");
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress("CMS Alerts", "noreply@foursat.ir"));
|
||||
message.To.Add(new MailboxAddress("Admin", adminEmail));
|
||||
message.Subject = $"[ALERT] {title}";
|
||||
message.Body = new TextPart("html") { Text = htmlMessage };
|
||||
|
||||
await client.SendAsync(message);
|
||||
```
|
||||
|
||||
- **B) SendGrid API**:
|
||||
```csharp
|
||||
var client = new SendGridClient(_settings.SendGridApiKey);
|
||||
var msg = MailHelper.CreateSingleEmail(
|
||||
from: new EmailAddress("noreply@foursat.ir", "CMS Alerts"),
|
||||
to: new EmailAddress(adminEmail),
|
||||
subject: $"[ALERT] {title}",
|
||||
plainTextContent: message,
|
||||
htmlContent: htmlMessage
|
||||
);
|
||||
await client.SendEmailAsync(msg);
|
||||
```
|
||||
|
||||
**Config**:
|
||||
```json
|
||||
{
|
||||
"Monitoring": {
|
||||
"EmailAlertsEnabled": true,
|
||||
"AdminEmails": ["admin@foursat.ir", "devops@foursat.ir"],
|
||||
"SmtpServer": "smtp.gmail.com",
|
||||
"SmtpPort": 587,
|
||||
"SmtpUsername": "user@example.com",
|
||||
"SmtpPassword": "password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Priority 5: Retry Logic با Exponential Backoff (Low - 1 hour)
|
||||
|
||||
**Why**: بهبود Reliability در صورت خطاهای Transient
|
||||
|
||||
**Implementation در Worker**:
|
||||
```csharp
|
||||
private async Task<T> RetryWithExponentialBackoffAsync<T>(
|
||||
Func<Task<T>> operation,
|
||||
int maxRetries = 3,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await operation();
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxRetries && IsTransientError(ex))
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 2^n: 1s, 2s, 4s
|
||||
|
||||
_logger.LogWarning(ex,
|
||||
"Attempt {Attempt}/{MaxRetries} failed. Retrying in {Delay}s...",
|
||||
attempt + 1, maxRetries, delay.TotalSeconds);
|
||||
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Operation failed after {maxRetries} retries");
|
||||
}
|
||||
|
||||
private bool IsTransientError(Exception ex)
|
||||
{
|
||||
return ex is TimeoutException
|
||||
|| ex is HttpRequestException
|
||||
|| (ex is SqlException sqlEx && sqlEx.IsTransient);
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```csharp
|
||||
// در ExecuteWeeklyCalculationAsync():
|
||||
var balancesCalculated = await RetryWithExponentialBackoffAsync(async () =>
|
||||
{
|
||||
return await mediator.Send(new CalculateWeeklyBalancesCommand
|
||||
{
|
||||
WeekNumber = previousWeekNumber
|
||||
}, cancellationToken);
|
||||
}, maxRetries: 3, ct: cancellationToken);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Guide
|
||||
|
||||
### Test 1: Alert Service (Console Logging)
|
||||
```csharp
|
||||
// در Controller یا Handler:
|
||||
var alertService = _serviceProvider.GetRequiredService<IAlertService>();
|
||||
|
||||
await alertService.SendCriticalAlertAsync(
|
||||
"Test Critical Alert",
|
||||
"این یک تست برای Alert Service است",
|
||||
new Exception("Sample exception"));
|
||||
|
||||
await alertService.SendSuccessNotificationAsync(
|
||||
"Test Success",
|
||||
"عملیات با موفقیت انجام شد");
|
||||
```
|
||||
|
||||
**Expected Output**:
|
||||
```
|
||||
🚨 CRITICAL ALERT: Test Critical Alert - این یک تست برای Alert Service است
|
||||
✅ SUCCESS: Test Success - عملیات با موفقیت انجام شد
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Test 2: User Notification Service
|
||||
```csharp
|
||||
var notificationService = _serviceProvider.GetRequiredService<IUserNotificationService>();
|
||||
|
||||
await notificationService.SendCommissionReceivedNotificationAsync(
|
||||
userId: 123,
|
||||
amount: 500_000,
|
||||
weekNumber: 48);
|
||||
```
|
||||
|
||||
**Expected Output**:
|
||||
```
|
||||
📧 Sending commission notification: User=123, Amount=500000, Week=48
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Test 3: Worker Integration
|
||||
```bash
|
||||
# Run Worker manually (for testing)
|
||||
# تغییر زمان اجرا به 1 دقیقه بعد برای تست:
|
||||
# در Worker: var delay = TimeSpan.FromMinutes(1);
|
||||
dotnet run --project CMSMicroservice.WebApi
|
||||
```
|
||||
|
||||
**Expected**:
|
||||
- Worker starts
|
||||
- After 1 minute → Executes calculation
|
||||
- On success → Logs: `✅ SUCCESS: Weekly Commission Completed`
|
||||
- On error → Logs: `🚨 CRITICAL ALERT: Weekly Commission Worker Failed`
|
||||
|
||||
---
|
||||
|
||||
### Test 4: Sentry Integration (بعد از پیادهسازی)
|
||||
```csharp
|
||||
// Throw یک exception برای تست:
|
||||
throw new InvalidOperationException("Test Sentry integration");
|
||||
```
|
||||
|
||||
**Check**: Sentry dashboard → Issues → باید exception جدید نمایش داده شود
|
||||
|
||||
---
|
||||
|
||||
### Test 5: Slack Integration (بعد از پیادهسازی)
|
||||
```csharp
|
||||
await alertService.SendCriticalAlertAsync("Test Slack", "Testing webhook integration", null);
|
||||
```
|
||||
|
||||
**Check**: Slack channel → باید پیام جدید نمایش داده شود
|
||||
|
||||
---
|
||||
|
||||
### Test 6: SMS Integration (بعد از پیادهسازی)
|
||||
```csharp
|
||||
await notificationService.SendCommissionReceivedNotificationAsync(
|
||||
userId: YOUR_USER_ID, // با شماره موبایل معتبر
|
||||
amount: 100_000,
|
||||
weekNumber: 48);
|
||||
```
|
||||
|
||||
**Check**: موبایل کاربر → باید SMS دریافت شود
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current Status & Progress
|
||||
|
||||
| Component | Status | Completion | Notes |
|
||||
|-----------|--------|------------|-------|
|
||||
| **Interfaces** | ✅ Done | 100% | `IAlertService`, `IUserNotificationService` |
|
||||
| **Skeleton Implementations** | ✅ Done | 100% | Logging only |
|
||||
| **Configuration Model** | ✅ Done | 100% | `MonitoringSettings` |
|
||||
| **DI Registration** | ✅ Done | 100% | In `ConfigureServices.cs` |
|
||||
| **Worker Integration** | ✅ Done | 100% | Success + Error alerts |
|
||||
| **appsettings Structure** | ✅ Done | 100% | Monitoring section added |
|
||||
| **Sentry Integration** | ⏳ Pending | 0% | Install package + configure DSN |
|
||||
| **Slack Webhook** | ⏳ Pending | 0% | Create webhook + implement POST |
|
||||
| **SMS Gateway** | ⏳ Pending | 0% | Choose provider + get API key |
|
||||
| **Email Alerts** | ⏳ Pending | 0% | SMTP/SendGrid integration |
|
||||
| **Retry Logic** | ⏳ Pending | 0% | Exponential backoff implementation |
|
||||
| **Testing** | ⏳ Pending | 0% | Unit + Integration tests |
|
||||
|
||||
**Overall Progress**: 30% ✅ | 70% ⏳
|
||||
|
||||
---
|
||||
|
||||
## 📝 Important Notes
|
||||
|
||||
### 1. Production Readiness
|
||||
- ⚠️ **فعلاً فقط Logging فعال است**
|
||||
- ⚠️ برای Production **حداقل Sentry** باید فعال شود
|
||||
- ⚠️ برای Critical systems حتماً Slack هم اضافه شود
|
||||
|
||||
### 2. User Preferences
|
||||
- SMS/Email/Push باید بر اساس تنظیمات کاربر (`User.SmsNotifications`, etc.) ارسال شود
|
||||
- در `UserNotificationService` باید ابتدا preferences چک شود
|
||||
|
||||
### 3. Rate Limiting
|
||||
- برای SMS Gateway باید Rate Limiting در نظر گرفته شود
|
||||
- پیشنهاد: استفاده از Queue (Hangfire/RabbitMQ) برای ارسال تعداد زیاد SMS
|
||||
|
||||
### 4. Cost Management
|
||||
- SMS و Email هزینه دارند
|
||||
- پیشنهاد: Batching برای ارسال گروهی
|
||||
- پیشنهاد: Template-based messaging برای کاهش هزینه
|
||||
|
||||
### 5. Security
|
||||
- API Keys در `appsettings.json` نباید commit شوند
|
||||
- استفاده از Environment Variables یا Azure Key Vault
|
||||
- مثال: `SmsApiKey: ${SMS_API_KEY}` در appsettings
|
||||
|
||||
### 6. Monitoring the Monitor
|
||||
- خود Alert System هم باید Monitor شود
|
||||
- اگر Slack/SMS fail شد، باید Fallback به Email یا Log باشد
|
||||
- پیشنهاد: Dead Letter Queue برای failed notifications
|
||||
|
||||
---
|
||||
|
||||
## 🔗 File Reference Map
|
||||
|
||||
```
|
||||
CMS/
|
||||
├── src/
|
||||
│ ├── CMSMicroservice.Application/
|
||||
│ │ └── Common/
|
||||
│ │ └── Interfaces/
|
||||
│ │ └── IAlertService.cs ⭐
|
||||
│ │
|
||||
│ ├── CMSMicroservice.Infrastructure/
|
||||
│ │ ├── Services/
|
||||
│ │ │ └── Monitoring/
|
||||
│ │ │ ├── AlertService.cs ⭐
|
||||
│ │ │ ├── UserNotificationService.cs ⭐
|
||||
│ │ │ └── MonitoringSettings.cs ⭐
|
||||
│ │ │
|
||||
│ │ ├── BackgroundJobs/
|
||||
│ │ │ └── WeeklyNetworkCommissionWorker.cs ✏️ (Modified)
|
||||
│ │ │
|
||||
│ │ └── ConfigureServices.cs ✏️ (Modified)
|
||||
│ │
|
||||
│ └── CMSMicroservice.WebApi/
|
||||
│ └── appsettings.json ✏️ (Modified)
|
||||
│
|
||||
└── docs/
|
||||
└── monitoring-alerts-implementation-report.md 📄 (This file)
|
||||
```
|
||||
|
||||
**Legend**:
|
||||
- ⭐ = New file created
|
||||
- ✏️ = Existing file modified
|
||||
- 📄 = Documentation
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Action Items
|
||||
|
||||
### Immediate (این هفته):
|
||||
1. ✅ Review this document
|
||||
2. ⏳ Decision: کدام Integration اول؟ (پیشنهاد: Sentry)
|
||||
3. ⏳ Get credentials:
|
||||
- Sentry DSN
|
||||
- Slack Webhook URL
|
||||
- SMS Gateway API Key
|
||||
|
||||
### Short-term (هفته آینده):
|
||||
4. ⏳ Implement Sentry integration
|
||||
5. ⏳ Implement Slack webhook
|
||||
6. ⏳ Test in Staging environment
|
||||
|
||||
### Long-term (ماه آینده):
|
||||
7. ⏳ Implement SMS Gateway (Kavenegar)
|
||||
8. ⏳ Add Email alerts
|
||||
9. ⏳ Implement Retry logic
|
||||
10. ⏳ Write Unit/Integration tests
|
||||
11. ⏳ Deploy to Production
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact & Support
|
||||
|
||||
**Implementation Questions**:
|
||||
- Developer: GitHub Copilot (این گزارش)
|
||||
- Review: Development Team
|
||||
|
||||
**Service Providers**:
|
||||
- **Sentry**: https://sentry.io (Error tracking)
|
||||
- **Slack**: https://api.slack.com/messaging/webhooks (Webhooks)
|
||||
- **Kavenegar**: https://kavenegar.com (SMS Gateway - Iran)
|
||||
- **Ghasedak**: https://ghasedak.me (SMS Gateway Alternative)
|
||||
- **SendGrid**: https://sendgrid.com (Email service)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-30
|
||||
**Build Status**: ✅ Success
|
||||
**Ready for**: Integration implementation
|
||||
|
||||
---
|
||||
|
||||
## 🎯 TL;DR (خلاصه برای رجوع سریع)
|
||||
|
||||
### چی ساخته شد:
|
||||
- ✅ `IAlertService` + `AlertService` (Admin alerts)
|
||||
- ✅ `IUserNotificationService` + `UserNotificationService` (User notifications)
|
||||
- ✅ `MonitoringSettings` (Configuration model)
|
||||
- ✅ Worker integration (Success/Error alerts)
|
||||
- ✅ DI registration
|
||||
- ✅ appsettings structure
|
||||
|
||||
### فعلاً چی کار میکنه:
|
||||
- Logging به Console (🚨 Critical, ⚠️ Warning, ✅ Success)
|
||||
|
||||
### چی باید اضافه بشه:
|
||||
1. **Sentry** - Error tracking (Priority: High)
|
||||
2. **Slack** - Real-time alerts (Priority: Medium)
|
||||
3. **SMS Gateway** - User notifications (Priority: Medium)
|
||||
4. **Email** - Backup channel (Priority: Low)
|
||||
5. **Retry Logic** - Reliability (Priority: Low)
|
||||
|
||||
### کجا باید نگاه کنی:
|
||||
- Interfaces: `CMSMicroservice.Application/Common/Interfaces/IAlertService.cs`
|
||||
- Implementations: `CMSMicroservice.Infrastructure/Services/Monitoring/`
|
||||
- Worker: `CMSMicroservice.Infrastructure/BackgroundJobs/WeeklyNetworkCommissionWorker.cs`
|
||||
- Config: `CMSMicroservice.WebApi/appsettings.json`
|
||||
|
||||
### چطوری تست کنی:
|
||||
```csharp
|
||||
await alertService.SendCriticalAlertAsync("Test", "Message", null);
|
||||
// Output: 🚨 CRITICAL ALERT: Test - Message
|
||||
```
|
||||
|
||||
### بعدش چیکار کنم:
|
||||
1. Get Sentry DSN → Update appsettings.Production.json
|
||||
2. Install `Sentry.AspNetCore` → Configure in Program.cs
|
||||
3. Update `AlertService.SendCriticalAlertAsync()` → Add `SentrySdk.CaptureException()`
|
||||
4. Test → Deploy
|
||||
@@ -1,333 +0,0 @@
|
||||
# 📊 Monitoring & Alerts System - Implementation Report
|
||||
|
||||
**Date**: 2025-11-30
|
||||
**Status**: ✅ Skeleton Implemented
|
||||
**Completion**: 30% (Structure ready, integrations pending)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
اسکلت سیستم **Monitoring & Alerts** برای پروژه CMS پیادهسازی شد. این سیستم به دو بخش اصلی تقسیم میشود:
|
||||
|
||||
1. **Alert System**: برای ارسال اعلانهای مدیریتی (Critical Errors, Warnings, Success)
|
||||
2. **User Notification System**: برای ارسال پیام به کاربران (کمیسیون، پرداخت، فعالسازی باشگاه)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Files Created/Modified
|
||||
|
||||
### ✨ New Files:
|
||||
|
||||
1. **`IAlertService.cs`** (Interface)
|
||||
- `SendCriticalAlertAsync()` - برای خطاهای Critical
|
||||
- `SendWarningAlertAsync()` - برای Warning ها
|
||||
- `SendSuccessNotificationAsync()` - برای موفقیتها
|
||||
|
||||
2. **`IUserNotificationService.cs`** (Interface)
|
||||
- `SendCommissionReceivedNotificationAsync()` - اعلان دریافت کمیسیون
|
||||
- `SendClubActivationNotificationAsync()` - اعلان فعالسازی باشگاه
|
||||
- `SendPayoutErrorNotificationAsync()` - اعلان خطا در پرداخت
|
||||
|
||||
3. **`AlertService.cs`** (Implementation - Skeleton)
|
||||
- ✅ Logging به Console
|
||||
- ⏳ TODO: Sentry Integration
|
||||
- ⏳ TODO: Slack Integration
|
||||
- ⏳ TODO: Email Integration
|
||||
|
||||
4. **`UserNotificationService.cs`** (Implementation - Skeleton)
|
||||
- ✅ Logging به Console
|
||||
- ⏳ TODO: SMS Gateway Integration
|
||||
- ⏳ TODO: Email Service Integration
|
||||
- ⏳ TODO: Push Notification Integration
|
||||
|
||||
5. **`MonitoringSettings.cs`** (Configuration Model)
|
||||
- تنظیمات Sentry, Slack, Email, SMS
|
||||
- قابل تنظیم از طریق `appsettings.json`
|
||||
|
||||
---
|
||||
|
||||
### ✏️ Modified Files:
|
||||
|
||||
1. **`ConfigureServices.cs`**
|
||||
```csharp
|
||||
services.AddScoped<IAlertService, AlertService>();
|
||||
services.AddScoped<IUserNotificationService, UserNotificationService>();
|
||||
```
|
||||
|
||||
2. **`WeeklyNetworkCommissionWorker.cs`**
|
||||
- ✅ Integration با `IAlertService`
|
||||
- ✅ ارسال Critical Alert در صورت خطا
|
||||
- ✅ ارسال Success Notification پس از اتمام موفق
|
||||
|
||||
3. **`appsettings.json`**
|
||||
- اضافه شدن بخش `Monitoring` با تنظیمات پیشفرض
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Current Implementation
|
||||
|
||||
### Alert System Usage:
|
||||
|
||||
```csharp
|
||||
// در Worker یا هر Handler دیگر:
|
||||
try
|
||||
{
|
||||
// عملیات خطرناک
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _alertService.SendCriticalAlertAsync(
|
||||
"Operation Failed",
|
||||
"Description of what went wrong",
|
||||
ex);
|
||||
}
|
||||
```
|
||||
|
||||
### Current Output:
|
||||
```
|
||||
🚨 CRITICAL ALERT: Weekly Commission Worker Failed - Worker execution abc-123 failed for week 2025-W48
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Pending Integrations (TODO)
|
||||
|
||||
### 1. Sentry Integration
|
||||
```csharp
|
||||
// در AlertService.SendCriticalAlertAsync():
|
||||
if (_settings.SentryEnabled)
|
||||
{
|
||||
SentrySdk.CaptureException(exception);
|
||||
}
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
- Install NuGet: `Sentry.AspNetCore`
|
||||
- Configure DSN in `appsettings.json`
|
||||
- Add to `Program.cs`: `builder.WebHost.UseSentry()`
|
||||
|
||||
---
|
||||
|
||||
### 2. Slack Integration
|
||||
```csharp
|
||||
// در AlertService:
|
||||
if (_settings.SlackEnabled)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
text = $"🚨 {title}",
|
||||
attachments = new[]
|
||||
{
|
||||
new { text = message, color = "danger" }
|
||||
}
|
||||
};
|
||||
|
||||
await _httpClient.PostAsJsonAsync(_settings.SlackWebhookUrl, payload);
|
||||
}
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
- Create Slack Incoming Webhook
|
||||
- Add URL to `appsettings.json`
|
||||
- Install NuGet: `System.Net.Http.Json`
|
||||
|
||||
---
|
||||
|
||||
### 3. Email Alerts (برای Admin)
|
||||
```csharp
|
||||
// در AlertService:
|
||||
if (_settings.EmailAlertsEnabled)
|
||||
{
|
||||
foreach (var email in _settings.AdminEmails)
|
||||
{
|
||||
await _emailService.SendAsync(
|
||||
to: email,
|
||||
subject: $"[ALERT] {title}",
|
||||
body: message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
- Configure SMTP settings
|
||||
- Install NuGet: `MailKit` or use existing email service
|
||||
- Add admin emails to config
|
||||
|
||||
---
|
||||
|
||||
### 4. SMS Notifications (برای کاربران)
|
||||
```csharp
|
||||
// در UserNotificationService.SendCommissionReceivedNotificationAsync():
|
||||
var user = await _context.Users.FindAsync(userId);
|
||||
|
||||
if (user.SmsNotifications && _settings.SmsNotificationsEnabled)
|
||||
{
|
||||
var message = $"کمیسیون شما: {amount:N0} ریال برای هفته {weekNumber} واریز شد.";
|
||||
|
||||
await _smsGateway.SendAsync(user.Mobile, message);
|
||||
}
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
- Choose SMS provider (Kavenegar, Ghasedak, etc.)
|
||||
- Get API Key
|
||||
- Implement `ISmsGatewayService`
|
||||
|
||||
---
|
||||
|
||||
### 5. Retry Logic با Exponential Backoff
|
||||
```csharp
|
||||
// در Worker:
|
||||
private async Task<T> RetryWithExponentialBackoff<T>(
|
||||
Func<Task<T>> operation,
|
||||
int maxRetries = 3)
|
||||
{
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await operation();
|
||||
}
|
||||
catch (Exception ex) when (i < maxRetries - 1)
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, i)); // 2^i seconds
|
||||
_logger.LogWarning("Retry {Attempt}/{Max} after {Delay}s",
|
||||
i + 1, maxRetries, delay.TotalSeconds);
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Configuration Example
|
||||
|
||||
در `appsettings.Production.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Monitoring": {
|
||||
"SentryEnabled": true,
|
||||
"SentryDsn": "https://xxxxx@sentry.io/12345",
|
||||
|
||||
"SlackEnabled": true,
|
||||
"SlackWebhookUrl": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX",
|
||||
|
||||
"EmailAlertsEnabled": true,
|
||||
"AdminEmails": [
|
||||
"admin@foursat.ir",
|
||||
"devops@foursat.ir"
|
||||
],
|
||||
|
||||
"SmsNotificationsEnabled": true,
|
||||
"SmsApiKey": "your-kavenegar-api-key",
|
||||
"SmsGatewayUrl": "https://api.kavenegar.com/v1/{apikey}/sms/send.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Test 1: Alert Service
|
||||
```csharp
|
||||
var alertService = serviceProvider.GetRequiredService<IAlertService>();
|
||||
|
||||
await alertService.SendCriticalAlertAsync(
|
||||
"Test Alert",
|
||||
"This is a test critical alert");
|
||||
```
|
||||
|
||||
**Expected**: Log در Console + (در Production) Sentry + Slack
|
||||
|
||||
---
|
||||
|
||||
### Test 2: User Notification
|
||||
```csharp
|
||||
var notificationService = serviceProvider.GetRequiredService<IUserNotificationService>();
|
||||
|
||||
await notificationService.SendCommissionReceivedNotificationAsync(
|
||||
userId: 123,
|
||||
amount: 500_000,
|
||||
weekNumber: 48);
|
||||
```
|
||||
|
||||
**Expected**: Log در Console + (در Production) SMS + Email
|
||||
|
||||
---
|
||||
|
||||
## 📊 Integration Priority
|
||||
|
||||
| Priority | Integration | Effort | Impact |
|
||||
|----------|------------|--------|--------|
|
||||
| 🔴 High | Sentry | 1 hour | Critical error tracking |
|
||||
| 🟡 Medium | Slack | 2 hours | Real-time admin alerts |
|
||||
| 🟡 Medium | SMS (Kavenegar) | 3 hours | User notifications |
|
||||
| 🟢 Low | Email Alerts | 2 hours | Backup notification channel |
|
||||
| 🟢 Low | Retry Logic | 1 hour | Reliability improvement |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Current Status Summary
|
||||
|
||||
### Completed (30%):
|
||||
- ✅ Interface definitions
|
||||
- ✅ Skeleton implementations with Logging
|
||||
- ✅ DI registration
|
||||
- ✅ Worker integration
|
||||
- ✅ Configuration model
|
||||
- ✅ appsettings structure
|
||||
|
||||
### Pending (70%):
|
||||
- ⏳ Sentry integration (5%)
|
||||
- ⏳ Slack webhook (10%)
|
||||
- ⏳ Email service (10%)
|
||||
- ⏳ SMS gateway (15%)
|
||||
- ⏳ Push notifications (10%)
|
||||
- ⏳ Retry logic (5%)
|
||||
- ⏳ Testing (10%)
|
||||
- ⏳ Documentation (5%)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Immediate** (در صورت نیاز):
|
||||
- Enable Sentry for error tracking
|
||||
- Setup Slack webhook for critical alerts
|
||||
|
||||
2. **Short-term** (هفته آینده):
|
||||
- Integrate SMS gateway (Kavenegar)
|
||||
- Test User notifications
|
||||
|
||||
3. **Long-term** (ماه آینده):
|
||||
- Add Email service
|
||||
- Implement Retry logic
|
||||
- Push notification service
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- تمام TODO ها در کد با comment مشخص شدهاند
|
||||
- فعلاً فقط Logging فعال است
|
||||
- برای Production باید حتماً یکی از Integration ها (Sentry/Slack) فعال شود
|
||||
- SMS Gateway باید بر اساس پروژه انتخاب شود (Kavenegar, Ghasedak, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
- **Interfaces**: `CMSMicroservice.Application/Common/Interfaces/IAlertService.cs`
|
||||
- **Implementations**: `CMSMicroservice.Infrastructure/Services/Monitoring/`
|
||||
- **Worker**: `CMSMicroservice.Infrastructure/BackgroundJobs/WeeklyNetworkCommissionWorker.cs`
|
||||
- **Config**: `CMSMicroservice.WebApi/appsettings.json`
|
||||
|
||||
---
|
||||
|
||||
**Report generated**: 2025-11-30
|
||||
**Build Status**: ✅ Success
|
||||
**Ready for**: Development continuation / Integration implementation
|
||||
@@ -1,905 +0,0 @@
|
||||
# سیستم باشگاه مشتریان و محاسبه کمیسیون شبکه
|
||||
|
||||
## خلاصه اجرایی
|
||||
این سند تحلیل جامع و معماری پیشنهادی برای پیادهسازی سیستم باشگاه مشتریان (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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.2.2" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
|
||||
|
||||
/// <summary>
|
||||
/// Command برای فعالسازی عضویت باشگاه مشتریان یک کاربر
|
||||
/// </summary>
|
||||
public record ActivateClubMembershipCommand : IRequest<long>
|
||||
public record ActivateClubMembershipCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// تاریخ فعالسازی (اختیاری - پیشفرض: الان)
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActivationDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// دلیل فعالسازی (برای History)
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,98 +1,220 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Entities.Club;
|
||||
using CMSMicroservice.Domain.Entities.History;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
|
||||
|
||||
public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClubMembershipCommand, long>
|
||||
public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClubMembershipCommand, bool>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly ILogger<ActivateClubMembershipCommandHandler> _logger;
|
||||
|
||||
public ActivateClubMembershipCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
ICurrentUserService currentUser,
|
||||
ILogger<ActivateClubMembershipCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<long> Handle(ActivateClubMembershipCommand request, CancellationToken cancellationToken)
|
||||
public async Task<bool> Handle(
|
||||
ActivateClubMembershipCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// بررسی وجود کاربر
|
||||
var userExists = await _context.Users
|
||||
.AnyAsync(x => x.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (!userExists)
|
||||
try
|
||||
{
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"Activating club membership for UserId: {UserId}",
|
||||
request.UserId
|
||||
);
|
||||
|
||||
// دریافت مبلغ عضویت از Configuration
|
||||
var activationFeeConfig = await _context.SystemConfigurations
|
||||
.Where(x => x.Key == "Club.ActivationFee" && x.IsActive)
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
long initialContribution = long.Parse(activationFeeConfig ?? "25000000"); // Default: 25 million Rials
|
||||
// 1. بررسی کاربر
|
||||
var user = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
// بررسی عضویت فعلی
|
||||
var existingMembership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(x => x.UserId == request.UserId, cancellationToken);
|
||||
|
||||
ClubMembership entity;
|
||||
bool isNewMembership = existingMembership == null;
|
||||
var activationDate = request.ActivationDate ?? DateTimeOffset.Now; // استفاده از Local Time
|
||||
|
||||
if (isNewMembership)
|
||||
{
|
||||
// ایجاد عضویت جدید
|
||||
// توجه: InitialContribution فقط ثبت میشود، از کیف پول کسر نمیشود!
|
||||
// کاربر قبلاً باید کیف پول خود را شارژ کرده باشد
|
||||
entity = new ClubMembership
|
||||
if (user == null)
|
||||
{
|
||||
UserId = request.UserId,
|
||||
IsActive = true,
|
||||
ActivatedAt = activationDate.DateTime,
|
||||
InitialContribution = initialContribution, // مبلغ عضویت از Configuration
|
||||
TotalEarned = 0
|
||||
};
|
||||
_logger.LogWarning("User not found: {UserId}", request.UserId);
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
}
|
||||
|
||||
await _context.ClubMemberships.AddAsync(entity, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// فعالسازی مجدد عضویت موجود
|
||||
entity = existingMembership;
|
||||
|
||||
if (entity.IsActive)
|
||||
// 2. بررسی اینکه پکیج خریده باشد
|
||||
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||
{
|
||||
// اگر از قبل فعال است، فقط تاریخ را بهروز میکنیم
|
||||
entity.ActivatedAt = activationDate.DateTime;
|
||||
_logger.LogWarning(
|
||||
"User {UserId} has not purchased golden package yet",
|
||||
request.UserId
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. بررسی موجودی کیف پول
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
|
||||
|
||||
if (wallet == null)
|
||||
{
|
||||
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
|
||||
throw new NotFoundException("کیف پول کاربر یافت نشد");
|
||||
}
|
||||
|
||||
if (wallet.Balance < 56_000_000)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"User {UserId} has insufficient balance: {Balance}",
|
||||
request.UserId,
|
||||
wallet.Balance
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید"
|
||||
);
|
||||
}
|
||||
|
||||
// 4. پیدا کردن UserOrder با PackageId
|
||||
var packageOrder = await _context.UserOrders
|
||||
.Include(o => o.Transaction)
|
||||
.Where(o =>
|
||||
o.UserId == user.Id &&
|
||||
o.PackageId != null &&
|
||||
o.PaymentStatus == PaymentStatus.Success)
|
||||
.OrderByDescending(o => o.Created)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (packageOrder == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No successful package order found for UserId: {UserId}",
|
||||
request.UserId
|
||||
);
|
||||
throw new NotFoundException("سفارش پکیج طلایی یافت نشد");
|
||||
}
|
||||
|
||||
// 5. بررسی Transaction
|
||||
if (packageOrder.Transaction == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Transaction not found for OrderId: {OrderId}",
|
||||
packageOrder.Id
|
||||
);
|
||||
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
|
||||
}
|
||||
|
||||
var transaction = packageOrder.Transaction;
|
||||
|
||||
if (transaction.Type != TransactionType.DepositIpg &&
|
||||
transaction.Type != TransactionType.DepositExternal1)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Invalid transaction type for OrderId {OrderId}: {Type}",
|
||||
packageOrder.Id,
|
||||
transaction.Type
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"تراکنش معتبر برای فعالسازی باشگاه یافت نشد"
|
||||
);
|
||||
}
|
||||
|
||||
// 6. بررسی عضویت فعلی
|
||||
var existingMembership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
|
||||
|
||||
ClubMembership entity;
|
||||
bool isNewMembership = existingMembership == null;
|
||||
var activationDate = DateTime.UtcNow;
|
||||
|
||||
if (isNewMembership)
|
||||
{
|
||||
// ایجاد عضویت جدید
|
||||
entity = new ClubMembership
|
||||
{
|
||||
UserId = user.Id,
|
||||
IsActive = true,
|
||||
ActivatedAt = activationDate,
|
||||
InitialContribution = 56_000_000,
|
||||
TotalEarned = 0,
|
||||
PurchaseMethod = user.PackagePurchaseMethod
|
||||
};
|
||||
|
||||
_context.ClubMemberships.Add(entity);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new club membership for UserId {UserId} via {Method}",
|
||||
user.Id,
|
||||
user.PackagePurchaseMethod
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
// فعالسازی عضویت غیرفعال
|
||||
if (existingMembership.IsActive)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"User {UserId} is already an active club member",
|
||||
user.Id
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// فعالسازی مجدد
|
||||
entity = existingMembership;
|
||||
entity.IsActive = true;
|
||||
entity.ActivatedAt = activationDate.DateTime;
|
||||
entity.ActivatedAt = activationDate;
|
||||
entity.PurchaseMethod = user.PackagePurchaseMethod;
|
||||
|
||||
_context.ClubMemberships.Update(entity);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Reactivated club membership for UserId {UserId}",
|
||||
user.Id
|
||||
);
|
||||
}
|
||||
|
||||
_context.ClubMemberships.Update(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 7. ثبت تاریخچه
|
||||
var history = new ClubMembershipHistory
|
||||
{
|
||||
ClubMembershipId = entity.Id,
|
||||
UserId = entity.UserId,
|
||||
OldIsActive = !isNewMembership && !existingMembership!.IsActive,
|
||||
NewIsActive = true,
|
||||
Action = ClubMembershipAction.Activated,
|
||||
Reason = isNewMembership
|
||||
? $"Initial activation via {user.PackagePurchaseMethod}"
|
||||
: $"Reactivated via {user.PackagePurchaseMethod}",
|
||||
PerformedBy = _currentUser.GetPerformedBy()
|
||||
};
|
||||
|
||||
_context.ClubMembershipHistories.Add(history);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}",
|
||||
user.Id,
|
||||
entity.Id
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// ثبت تاریخچه
|
||||
var history = new ClubMembershipHistory
|
||||
catch (Exception ex)
|
||||
{
|
||||
ClubMembershipId = entity.Id,
|
||||
UserId = entity.UserId,
|
||||
OldIsActive = !isNewMembership && !existingMembership!.IsActive,
|
||||
NewIsActive = true,
|
||||
Action = ClubMembershipAction.Activated,
|
||||
Reason = request.Reason ?? (isNewMembership ? "Initial activation" : "Reactivated"),
|
||||
PerformedBy = _currentUser.GetPerformedBy()
|
||||
};
|
||||
|
||||
await _context.ClubMembershipHistories.AddAsync(history, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return entity.Id;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error in ActivateClubMembershipCommand for UserId: {UserId}",
|
||||
request.UserId
|
||||
);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,6 @@ public class ActivateClubMembershipCommandValidator : AbstractValidator<Activate
|
||||
RuleFor(x => x.UserId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه کاربر معتبر نیست");
|
||||
|
||||
RuleFor(x => x.ActivationDate)
|
||||
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddDays(1))
|
||||
.WithMessage("تاریخ فعالسازی نمیتواند در آینده باشد")
|
||||
.When(x => x.ActivationDate.HasValue);
|
||||
|
||||
RuleFor(x => x.Reason)
|
||||
.MaximumLength(500)
|
||||
.WithMessage("دلیل فعالسازی نمیتواند بیشتر از 500 کاراکتر باشد")
|
||||
.When(x => !string.IsNullOrEmpty(x.Reason));
|
||||
}
|
||||
|
||||
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessWithdrawal;
|
||||
|
||||
public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawalCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
private readonly ILogger<ProcessWithdrawalCommandHandler> _logger;
|
||||
|
||||
public ProcessWithdrawalCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
ICurrentUserService currentUser,
|
||||
IPaymentGatewayService paymentGateway,
|
||||
ILogger<ProcessWithdrawalCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
_paymentGateway = paymentGateway;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(ProcessWithdrawalCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var payout = await _context.UserCommissionPayouts
|
||||
.Include(x => x.User)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.PayoutId, cancellationToken);
|
||||
|
||||
if (payout == null)
|
||||
@@ -35,12 +44,9 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
|
||||
if (request.IsApproved)
|
||||
{
|
||||
// تایید برداشت
|
||||
payout.Status = CommissionPayoutStatus.Withdrawn;
|
||||
payout.WithdrawnAt = now;
|
||||
|
||||
// اگر روش برداشت Diamond بود، باید مبلغ به کیف پول تخفیف اضافه شود
|
||||
if (payout.WithdrawalMethod == WithdrawalMethod.Diamond)
|
||||
{
|
||||
// روش Diamond: شارژ کیف پول تخفیف
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(x => x.UserId == payout.UserId, cancellationToken);
|
||||
|
||||
@@ -49,6 +55,61 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
|
||||
wallet.DiscountBalance += payout.TotalAmount;
|
||||
_context.UserWallets.Update(wallet);
|
||||
}
|
||||
|
||||
payout.Status = CommissionPayoutStatus.Withdrawn;
|
||||
payout.WithdrawnAt = now;
|
||||
}
|
||||
else if (payout.WithdrawalMethod == WithdrawalMethod.Cash)
|
||||
{
|
||||
// روش انتقال بانکی: فراخوانی Payment Gateway
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Processing bank transfer for Payout {PayoutId}, User {UserId}, Amount {Amount}",
|
||||
payout.Id, payout.UserId, payout.TotalAmount);
|
||||
|
||||
var payoutRequest = new PayoutRequest
|
||||
{
|
||||
Amount = payout.TotalAmount,
|
||||
UserId = payout.UserId,
|
||||
Iban = payout.IbanNumber ?? throw new InvalidOperationException("شماره شبا یافت نشد"),
|
||||
AccountHolderName = $"{payout.User.FirstName} {payout.User.LastName}",
|
||||
Description = $"برداشت کمیسیون هفته {payout.WeekNumber}",
|
||||
InternalRefId = $"PAYOUT-{payout.Id}"
|
||||
};
|
||||
|
||||
var payoutResult = await _paymentGateway.ProcessPayoutAsync(payoutRequest, cancellationToken);
|
||||
|
||||
if (payoutResult.IsSuccess)
|
||||
{
|
||||
payout.Status = CommissionPayoutStatus.Withdrawn;
|
||||
payout.WithdrawnAt = now;
|
||||
payout.BankReferenceId = payoutResult.BankRefId;
|
||||
payout.BankTrackingCode = payoutResult.TrackingCode;
|
||||
|
||||
_logger.LogInformation("Bank transfer successful: Payout {PayoutId}, BankRef {BankRef}",
|
||||
payout.Id, payoutResult.BankRefId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// خطا در واریز
|
||||
payout.Status = CommissionPayoutStatus.PaymentFailed;
|
||||
payout.PaymentFailureReason = payoutResult.Message;
|
||||
|
||||
_logger.LogError("Bank transfer failed: Payout {PayoutId}, Reason: {Reason}",
|
||||
payout.Id, payoutResult.Message);
|
||||
|
||||
throw new InvalidOperationException($"خطا در واریز: {payoutResult.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception during bank transfer for Payout {PayoutId}", payout.Id);
|
||||
|
||||
payout.Status = CommissionPayoutStatus.PaymentFailed;
|
||||
payout.PaymentFailureReason = ex.Message;
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
_context.UserCommissionPayouts.Update(payout);
|
||||
@@ -63,9 +124,9 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
|
||||
AmountBefore = payout.TotalAmount,
|
||||
AmountAfter = payout.TotalAmount,
|
||||
OldStatus = oldStatus,
|
||||
NewStatus = CommissionPayoutStatus.Withdrawn,
|
||||
NewStatus = payout.Status,
|
||||
Action = CommissionPayoutAction.Withdrawn,
|
||||
PerformedBy = "Admin", // TODO: باید از Current User گرفته شود
|
||||
PerformedBy = _currentUser.UserId ?? "Admin",
|
||||
Reason = $"تایید برداشت به روش {payout.WithdrawalMethod}"
|
||||
};
|
||||
|
||||
@@ -92,7 +153,7 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
|
||||
OldStatus = oldStatus,
|
||||
NewStatus = CommissionPayoutStatus.Paid,
|
||||
Action = CommissionPayoutAction.Cancelled,
|
||||
PerformedBy = "Admin", // TODO: باید از Current User گرفته شود
|
||||
PerformedBy = _currentUser.UserId ?? "Admin",
|
||||
Reason = request.Reason ?? "درخواست برداشت رد شد"
|
||||
};
|
||||
|
||||
|
||||
@@ -35,5 +35,6 @@ public interface IApplicationDbContext
|
||||
DbSet<UserCommissionPayout> UserCommissionPayouts { get; }
|
||||
DbSet<CommissionPayoutHistory> CommissionPayoutHistories { get; }
|
||||
DbSet<WorkerExecutionLog> WorkerExecutionLogs { get; }
|
||||
DbSet<DayaLoanContract> DayaLoanContracts { get; }
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface برای یکپارچهسازی با درگاههای پرداخت
|
||||
/// </summary>
|
||||
public interface IPaymentGatewayService
|
||||
{
|
||||
/// <summary>
|
||||
/// شروع تراکنش پرداخت (ارسال به درگاه)
|
||||
/// </summary>
|
||||
/// <param name="request">اطلاعات تراکنش</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>URL درگاه برای هدایت کاربر + RefId تراکنش</returns>
|
||||
Task<PaymentInitiateResult> InitiatePaymentAsync(
|
||||
PaymentRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// تأیید پرداخت (بعد از بازگشت از درگاه)
|
||||
/// </summary>
|
||||
/// <param name="refId">شماره مرجع تراکنش</param>
|
||||
/// <param name="verificationToken">توکن تأیید از درگاه</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>وضعیت نهایی تراکنش</returns>
|
||||
Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||||
string refId,
|
||||
string verificationToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
|
||||
/// </summary>
|
||||
/// <param name="request">اطلاعات واریز</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>وضعیت واریز</returns>
|
||||
Task<PayoutResult> ProcessPayoutAsync(
|
||||
PayoutRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// درخواست شروع تراکنش پرداخت
|
||||
/// </summary>
|
||||
public class PaymentRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// مبلغ (تومان)
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره موبایل
|
||||
/// </summary>
|
||||
public string Mobile { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// شرح تراکنش
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// URL بازگشت بعد از پرداخت
|
||||
/// </summary>
|
||||
public string CallbackUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// نتیجه شروع تراکنش
|
||||
/// </summary>
|
||||
public class PaymentInitiateResult
|
||||
{
|
||||
/// <summary>
|
||||
/// موفق بودن درخواست
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره مرجع تراکنش (RefId)
|
||||
/// </summary>
|
||||
public string? RefId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URL درگاه برای هدایت کاربر
|
||||
/// </summary>
|
||||
public string? GatewayUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// پیام خطا (در صورت ناموفق بودن)
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// نتیجه تأیید تراکنش
|
||||
/// </summary>
|
||||
public class PaymentVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// موفق بودن تراکنش
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره مرجع تراکنش
|
||||
/// </summary>
|
||||
public string RefId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// کد پیگیری بانک
|
||||
/// </summary>
|
||||
public string? TrackingCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ تراکنش
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// پیام
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// درخواست واریز
|
||||
/// </summary>
|
||||
public class PayoutRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// مبلغ (تومان)
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره شبا
|
||||
/// </summary>
|
||||
public string Iban { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// نام صاحب حساب
|
||||
/// </summary>
|
||||
public string AccountHolderName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// شرح واریز
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// شماره مرجع داخلی
|
||||
/// </summary>
|
||||
public string InternalRefId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// نتیجه واریز
|
||||
/// </summary>
|
||||
public class PayoutResult
|
||||
{
|
||||
/// <summary>
|
||||
/// موفق بودن واریز
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره مرجع تراکنش بانکی
|
||||
/// </summary>
|
||||
public string? BankRefId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// کد پیگیری
|
||||
/// </summary>
|
||||
public string? TrackingCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// پیام
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// زمان پردازش
|
||||
/// </summary>
|
||||
public DateTime ProcessedAt { get; set; }
|
||||
}
|
||||
@@ -4,7 +4,8 @@ public class TransactionsProfile : IRegister
|
||||
{
|
||||
void IRegister.Register(TypeAdapterConfig config)
|
||||
{
|
||||
//config.NewConfig<Source,Destination>()
|
||||
// .Map(dest => dest.FullName, src => $"{src.Firstname} {src.Lastname}");
|
||||
// VerifyTransactionCommand → domain mapping handled in handler
|
||||
|
||||
// RefundTransactionCommand → domain mapping handled in handler
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckDayaLoanStatus;
|
||||
|
||||
/// <summary>
|
||||
/// Command برای استعلام وضعیت وام از سرویس دایا
|
||||
/// </summary>
|
||||
public record CheckDayaLoanStatusCommand : IRequest<CheckDayaLoanStatusResponseDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// لیست کدهای ملی برای استعلام
|
||||
/// </summary>
|
||||
public List<string> NationalCodes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using CMSMicroservice.Domain.Events;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using CMSMicroservice.Application.DayaLoanCQ.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckDayaLoanStatus;
|
||||
|
||||
public class CheckDayaLoanStatusCommandHandler : IRequestHandler<CheckDayaLoanStatusCommand, CheckDayaLoanStatusResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IDayaLoanApiService _dayaApiService;
|
||||
private readonly ILogger<CheckDayaLoanStatusCommandHandler> _logger;
|
||||
|
||||
public CheckDayaLoanStatusCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IDayaLoanApiService dayaApiService,
|
||||
ILogger<CheckDayaLoanStatusCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_dayaApiService = dayaApiService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CheckDayaLoanStatusResponseDto> Handle(CheckDayaLoanStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<DayaLoanStatusItem>();
|
||||
|
||||
try
|
||||
{
|
||||
// فراخوانی سرویس دایا (Mock یا Real)
|
||||
var dayaResults = await _dayaApiService.CheckLoanStatusAsync(request.NationalCodes, cancellationToken);
|
||||
|
||||
foreach (var dayaResult in dayaResults)
|
||||
{
|
||||
try
|
||||
{
|
||||
results.Add(new DayaLoanStatusItem
|
||||
{
|
||||
NationalCode = dayaResult.NationalCode,
|
||||
Status = dayaResult.Status,
|
||||
ContractNumber = dayaResult.ContractNumber,
|
||||
Message = "استعلام موفق"
|
||||
});
|
||||
|
||||
// ذخیره یا بهروزرسانی در دیتابیس
|
||||
var existingContract = await _context.DayaLoanContracts
|
||||
.FirstOrDefaultAsync(d => d.NationalCode == dayaResult.NationalCode, cancellationToken);
|
||||
|
||||
if (existingContract != null)
|
||||
{
|
||||
existingContract.LastCheckDate = DateTime.UtcNow;
|
||||
existingContract.Status = dayaResult.Status;
|
||||
existingContract.ContractNumber = dayaResult.ContractNumber;
|
||||
}
|
||||
else
|
||||
{
|
||||
var user = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.NationalCode == dayaResult.NationalCode, cancellationToken);
|
||||
|
||||
if (user != null)
|
||||
{
|
||||
var newContract = new DayaLoanContract
|
||||
{
|
||||
UserId = user.Id,
|
||||
NationalCode = dayaResult.NationalCode,
|
||||
Status = dayaResult.Status,
|
||||
ContractNumber = dayaResult.ContractNumber,
|
||||
LastCheckDate = DateTime.UtcNow,
|
||||
IsProcessed = false
|
||||
};
|
||||
|
||||
await _context.DayaLoanContracts.AddAsync(newContract, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing Daya result for {NationalCode}", dayaResult.NationalCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error calling Daya API service");
|
||||
|
||||
// در صورت خطا، نتایج خالی برمیگردانیم
|
||||
foreach (var nationalCode in request.NationalCodes)
|
||||
{
|
||||
results.Add(new DayaLoanStatusItem
|
||||
{
|
||||
NationalCode = nationalCode,
|
||||
Status = DayaLoanStatus.PendingReceive,
|
||||
ContractNumber = null,
|
||||
Message = $"خطا در استعلام: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new CheckDayaLoanStatusResponseDto
|
||||
{
|
||||
Results = results,
|
||||
TotalChecked = request.NationalCodes.Count,
|
||||
SuccessCount = results.Count(r => r.ContractNumber != null)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckDayaLoanStatus;
|
||||
|
||||
public class CheckDayaLoanStatusResponseDto
|
||||
{
|
||||
public List<DayaLoanStatusItem> Results { get; set; }
|
||||
public int TotalChecked { get; set; }
|
||||
public int SuccessCount { get; set; }
|
||||
}
|
||||
|
||||
public class DayaLoanStatusItem
|
||||
{
|
||||
public string NationalCode { get; set; }
|
||||
public DayaLoanStatus Status { get; set; }
|
||||
public string? ContractNumber { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
|
||||
|
||||
/// <summary>
|
||||
/// Command برای پردازش تایید وام دایا و شارژ کیف پول
|
||||
/// </summary>
|
||||
public record ProcessDayaLoanApprovalCommand : IRequest<ProcessDayaLoanApprovalResponseDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره قرارداد دایا
|
||||
/// </summary>
|
||||
public string ContractNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ کیف پول عادی (56 میلیون)
|
||||
/// </summary>
|
||||
public long WalletAmount { get; init; } = 56_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ کیف پول قفل شده (56 میلیون)
|
||||
/// </summary>
|
||||
public long LockedWalletAmount { get; init; } = 56_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ کیف پول تخفیف (56 میلیون)
|
||||
/// </summary>
|
||||
public long DiscountWalletAmount { get; init; } = 56_000_000;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using CMSMicroservice.Domain.Events;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
|
||||
|
||||
public class ProcessDayaLoanApprovalCommandHandler : IRequestHandler<ProcessDayaLoanApprovalCommand, ProcessDayaLoanApprovalResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public ProcessDayaLoanApprovalCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<ProcessDayaLoanApprovalResponseDto> Handle(ProcessDayaLoanApprovalCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// پیدا کردن کاربر
|
||||
var user = await _context.Users
|
||||
.Include(u => u.UserWallets)
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
}
|
||||
|
||||
// چک کردن که قبلاً دریافت نکرده باشد
|
||||
if (user.HasReceivedDayaCredit)
|
||||
{
|
||||
throw new InvalidOperationException($"کاربر {request.UserId} قبلاً اعتبار دایا را دریافت کرده است");
|
||||
}
|
||||
|
||||
// ایجاد تراکنش با RefId = شماره قرارداد دایا
|
||||
var transaction = new Transactions
|
||||
{
|
||||
Amount = request.WalletAmount + request.LockedWalletAmount + request.DiscountWalletAmount, // 168 میلیون
|
||||
Description = $"دریافت اعتبار دایا - قرارداد {request.ContractNumber}",
|
||||
PaymentStatus = PaymentStatus.Success,
|
||||
PaymentDate = DateTime.UtcNow,
|
||||
RefId = request.ContractNumber, // شماره قرارداد دایا
|
||||
Type = TransactionType.DepositExternal1
|
||||
};
|
||||
|
||||
await _context.Transactionss.AddAsync(transaction, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// یافتن یا ایجاد کیف پول کاربر
|
||||
var wallet = user.UserWallets.FirstOrDefault();
|
||||
if (wallet == null)
|
||||
{
|
||||
wallet = new UserWallet
|
||||
{
|
||||
UserId = request.UserId,
|
||||
Balance = 0,
|
||||
NetworkBalance = 0,
|
||||
DiscountBalance = 0
|
||||
};
|
||||
await _context.UserWallets.AddAsync(wallet, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// شارژ کیف پول عادی (56 میلیون)
|
||||
var balanceBeforeMain = wallet.Balance;
|
||||
wallet.Balance += request.WalletAmount;
|
||||
|
||||
// لاگ کیف پول عادی
|
||||
var mainLog = new UserWalletChangeLog
|
||||
{
|
||||
WalletId = wallet.Id,
|
||||
CurrentBalance = wallet.Balance,
|
||||
ChangeValue = request.WalletAmount,
|
||||
CurrentNetworkBalance = wallet.NetworkBalance,
|
||||
ChangeNerworkValue = 0,
|
||||
IsIncrease = true,
|
||||
RefrenceId = transaction.Id
|
||||
};
|
||||
await _context.UserWalletChangeLogs.AddAsync(mainLog, cancellationToken);
|
||||
|
||||
// شارژ کیف پول شبکه/کارمزد (56 میلیون) - نامگذاری قدیم: کیف پول قفل شده
|
||||
var balanceBeforeLocked = wallet.NetworkBalance;
|
||||
wallet.NetworkBalance += request.LockedWalletAmount;
|
||||
|
||||
// لاگ کیف پول شبکه
|
||||
var networkLog = new UserWalletChangeLog
|
||||
{
|
||||
WalletId = wallet.Id,
|
||||
CurrentBalance = wallet.Balance,
|
||||
ChangeValue = 0,
|
||||
CurrentNetworkBalance = wallet.NetworkBalance,
|
||||
ChangeNerworkValue = request.LockedWalletAmount,
|
||||
IsIncrease = true,
|
||||
RefrenceId = transaction.Id
|
||||
};
|
||||
await _context.UserWalletChangeLogs.AddAsync(networkLog, cancellationToken);
|
||||
|
||||
// شارژ کیف پول تخفیف (56 میلیون)
|
||||
var balanceBeforeDiscount = wallet.DiscountBalance;
|
||||
wallet.DiscountBalance += request.DiscountWalletAmount;
|
||||
|
||||
// لاگ کیف پول تخفیف
|
||||
var discountLog = new UserWalletChangeLog
|
||||
{
|
||||
WalletId = wallet.Id,
|
||||
CurrentBalance = wallet.Balance,
|
||||
ChangeValue = 0,
|
||||
CurrentNetworkBalance = wallet.NetworkBalance,
|
||||
ChangeNerworkValue = 0,
|
||||
CurrentDiscountBalance = wallet.DiscountBalance,
|
||||
ChangeDiscountValue = request.DiscountWalletAmount,
|
||||
IsIncrease = true,
|
||||
RefrenceId = transaction.Id
|
||||
};
|
||||
await _context.UserWalletChangeLogs.AddAsync(discountLog, cancellationToken);
|
||||
|
||||
// بهروزرسانی وضعیت کاربر
|
||||
user.HasReceivedDayaCredit = true;
|
||||
user.DayaCreditReceivedAt = DateTime.UtcNow;
|
||||
|
||||
// تنظیم نحوه خرید پکیج به DayaLoan
|
||||
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan;
|
||||
|
||||
// ثبت سفارش پکیج طلایی
|
||||
var goldenPackage = await _context.Packages
|
||||
.FirstOrDefaultAsync(p => p.Title.Contains("طلایی") || p.Title.Contains("Golden"), cancellationToken);
|
||||
|
||||
if (goldenPackage != null)
|
||||
{
|
||||
// پیدا کردن آدرس پیشفرض کاربر
|
||||
var defaultAddress = await _context.UserAddresss
|
||||
.Where(a => a.UserId == request.UserId)
|
||||
.OrderByDescending(a => a.Created)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (defaultAddress != null)
|
||||
{
|
||||
var packageOrder = new UserOrder
|
||||
{
|
||||
UserId = request.UserId,
|
||||
PackageId = goldenPackage.Id,
|
||||
Amount = request.WalletAmount, // 56 میلیون
|
||||
PaymentStatus = PaymentStatus.Success,
|
||||
PaymentDate = DateTime.UtcNow,
|
||||
DeliveryStatus = DeliveryStatus.None,
|
||||
UserAddressId = defaultAddress.Id,
|
||||
TransactionId = transaction.Id,
|
||||
PaymentMethod = PaymentMethod.IPG
|
||||
};
|
||||
|
||||
await _context.UserOrders.AddAsync(packageOrder, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// ثبت Event
|
||||
user.AddDomainEvent(new DayaLoanApprovedEvent(user, transaction, request.ContractNumber));
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new ProcessDayaLoanApprovalResponseDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TransactionId = transaction.Id,
|
||||
ContractNumber = request.ContractNumber,
|
||||
MainWalletBalance = wallet.Balance,
|
||||
LockedWalletBalance = wallet.NetworkBalance,
|
||||
DiscountWalletBalance = wallet.DiscountBalance,
|
||||
Message = "اعتبار دایا با موفقیت دریافت شد"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
|
||||
|
||||
public class ProcessDayaLoanApprovalCommandValidator : AbstractValidator<ProcessDayaLoanApprovalCommand>
|
||||
{
|
||||
public ProcessDayaLoanApprovalCommandValidator()
|
||||
{
|
||||
RuleFor(v => v.UserId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
|
||||
|
||||
RuleFor(v => v.ContractNumber)
|
||||
.NotEmpty()
|
||||
.WithMessage("شماره قرارداد الزامی است")
|
||||
.MaximumLength(100)
|
||||
.WithMessage("شماره قرارداد نباید بیش از 100 کاراکتر باشد");
|
||||
|
||||
RuleFor(v => v.WalletAmount)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("مبلغ کیف پول باید بزرگتر از صفر باشد");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Domain.Events;
|
||||
|
||||
namespace CMSMicroservice.Application.DayaLoanCQ.EventHandlers.DayaLoanApprovedEventHandlers;
|
||||
|
||||
public class DayaLoanApprovedEventHandler : INotificationHandler<DayaLoanApprovedEvent>
|
||||
{
|
||||
private readonly ILogger<DayaLoanApprovedEventHandler> _logger;
|
||||
|
||||
public DayaLoanApprovedEventHandler(ILogger<DayaLoanApprovedEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task Handle(DayaLoanApprovedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Daya loan approved for user {UserId}. Contract: {ContractNumber}, Transaction: {TransactionId}",
|
||||
notification.User.Id,
|
||||
notification.ContractNumber,
|
||||
notification.Transaction.Id);
|
||||
|
||||
// اینجا میتونیم اعلان به کاربر بفرستیم (Email/SMS)
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.DayaLoanCQ.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Daya Loan API Service
|
||||
/// این سرویس برای ارتباط با API واقعی دایا استفاده میشود
|
||||
/// </summary>
|
||||
public interface IDayaLoanApiService
|
||||
{
|
||||
/// <summary>
|
||||
/// استعلام وضعیت وام دایا برای یک لیست کدملی
|
||||
/// </summary>
|
||||
/// <param name="nationalCodes">لیست کدملیهای کاربران</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>وضعیت وام به همراه شماره قرارداد (در صورت وجود)</returns>
|
||||
Task<List<DayaLoanStatusResult>> CheckLoanStatusAsync(
|
||||
List<string> nationalCodes,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// نتیجه استعلام وضعیت وام از سرویس دایا
|
||||
/// </summary>
|
||||
public class DayaLoanStatusResult
|
||||
{
|
||||
public string NationalCode { get; set; } = string.Empty;
|
||||
public DayaLoanStatus Status { get; set; }
|
||||
public string? ContractNumber { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.PackageCQ.Commands.PurchaseGoldenPackage;
|
||||
|
||||
/// <summary>
|
||||
/// دستور خرید پکیج طلایی از طریق درگاه بانکی
|
||||
/// </summary>
|
||||
public class PurchaseGoldenPackageCommand : IRequest<PaymentInitiateResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ValidationException = FluentValidation.ValidationException;
|
||||
|
||||
namespace CMSMicroservice.Application.PackageCQ.Commands.PurchaseGoldenPackage;
|
||||
|
||||
public class PurchaseGoldenPackageCommandHandler
|
||||
: IRequestHandler<PurchaseGoldenPackageCommand, PaymentInitiateResult>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
private readonly ILogger<PurchaseGoldenPackageCommandHandler> _logger;
|
||||
|
||||
public PurchaseGoldenPackageCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IPaymentGatewayService paymentGateway,
|
||||
ILogger<PurchaseGoldenPackageCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_paymentGateway = paymentGateway;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PaymentInitiateResult> Handle(
|
||||
PurchaseGoldenPackageCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Starting golden package purchase for UserId: {UserId}",
|
||||
request.UserId
|
||||
);
|
||||
|
||||
// 1. بررسی وجود کاربر
|
||||
var user = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("User not found: {UserId}", request.UserId);
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
}
|
||||
|
||||
// 2. بررسی اینکه قبلاً پکیج نخریده باشد
|
||||
if (user.PackagePurchaseMethod != PackagePurchaseMethod.None)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"User {UserId} has already purchased package via {Method}",
|
||||
request.UserId,
|
||||
user.PackagePurchaseMethod
|
||||
);
|
||||
throw new ValidationException(
|
||||
"شما قبلاً پکیج طلایی را خریداری کردهاید"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. پیدا کردن پکیج طلایی
|
||||
var goldenPackage = await _context.Packages
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Title.Contains("طلایی") || p.Title.Contains("Golden"),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (goldenPackage == null)
|
||||
{
|
||||
_logger.LogError("Golden package not found in database");
|
||||
throw new NotFoundException("پکیج طلایی یافت نشد");
|
||||
}
|
||||
|
||||
// 4. پیدا کردن آدرس پیشفرض کاربر (برای فیلد اجباری)
|
||||
var defaultAddress = await _context.UserAddresss
|
||||
.Where(a => a.UserId == request.UserId)
|
||||
.OrderByDescending(a => a.Created)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (defaultAddress == null)
|
||||
{
|
||||
_logger.LogWarning("No address found for user {UserId}", request.UserId);
|
||||
throw new ValidationException(
|
||||
"لطفاً ابتدا یک آدرس برای خود ثبت کنید"
|
||||
);
|
||||
}
|
||||
|
||||
// 5. ایجاد سفارش
|
||||
var order = new UserOrder
|
||||
{
|
||||
UserId = user.Id,
|
||||
PackageId = goldenPackage.Id,
|
||||
Amount = goldenPackage.Price, // 56,000,000 تومان
|
||||
PaymentStatus = PaymentStatus.Pending,
|
||||
DeliveryStatus = DeliveryStatus.None,
|
||||
UserAddressId = defaultAddress.Id,
|
||||
PaymentMethod = PaymentMethod.IPG
|
||||
};
|
||||
|
||||
_context.UserOrders.Add(order);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created UserOrder {OrderId} for UserId {UserId}, Amount: {Amount}",
|
||||
order.Id,
|
||||
request.UserId,
|
||||
order.Amount
|
||||
);
|
||||
|
||||
// 6. ایجاد درخواست پرداخت از درگاه
|
||||
var paymentRequest = new PaymentRequest
|
||||
{
|
||||
Amount = order.Amount,
|
||||
UserId = user.Id,
|
||||
Mobile = user.Mobile ?? "",
|
||||
CallbackUrl = $"https://yourdomain.com/api/package/verify-golden-package",
|
||||
Description = $"خرید پکیج طلایی - سفارش #{order.Id}"
|
||||
};
|
||||
|
||||
var paymentResult = await _paymentGateway.InitiatePaymentAsync(paymentRequest);
|
||||
|
||||
if (!paymentResult.IsSuccess)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Payment gateway failed for OrderId {OrderId}: {ErrorMessage}",
|
||||
order.Id,
|
||||
paymentResult.ErrorMessage
|
||||
);
|
||||
|
||||
// بهروزرسانی وضعیت سفارش
|
||||
order.PaymentStatus = PaymentStatus.Reject;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
throw new Exception(
|
||||
$"خطا در ارتباط با درگاه پرداخت: {paymentResult.ErrorMessage}"
|
||||
);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Payment initiated successfully. OrderId: {OrderId}, RefId: {RefId}",
|
||||
order.Id,
|
||||
paymentResult.RefId
|
||||
);
|
||||
|
||||
return paymentResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error in PurchaseGoldenPackageCommand for UserId: {UserId}",
|
||||
request.UserId
|
||||
);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace CMSMicroservice.Application.PackageCQ.Commands.PurchaseGoldenPackage;
|
||||
|
||||
public class PurchaseGoldenPackageCommandValidator : AbstractValidator<PurchaseGoldenPackageCommand>
|
||||
{
|
||||
public PurchaseGoldenPackageCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyGoldenPackagePurchase;
|
||||
|
||||
/// <summary>
|
||||
/// دستور تأیید پرداخت پکیج طلایی و شارژ کیف پول
|
||||
/// </summary>
|
||||
public class VerifyGoldenPackagePurchaseCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه سفارش
|
||||
/// </summary>
|
||||
public long OrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// کد Authority از درگاه
|
||||
/// </summary>
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ValidationException = FluentValidation.ValidationException;
|
||||
|
||||
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyGoldenPackagePurchase;
|
||||
|
||||
public class VerifyGoldenPackagePurchaseCommandHandler
|
||||
: IRequestHandler<VerifyGoldenPackagePurchaseCommand, bool>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
private readonly ILogger<VerifyGoldenPackagePurchaseCommandHandler> _logger;
|
||||
|
||||
public VerifyGoldenPackagePurchaseCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IPaymentGatewayService paymentGateway,
|
||||
ILogger<VerifyGoldenPackagePurchaseCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_paymentGateway = paymentGateway;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
VerifyGoldenPackagePurchaseCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Verifying golden package purchase. OrderId: {OrderId}, Authority: {Authority}",
|
||||
request.OrderId,
|
||||
request.Authority
|
||||
);
|
||||
|
||||
// 1. پیدا کردن سفارش
|
||||
var order = await _context.UserOrders
|
||||
.Include(o => o.Package)
|
||||
.Include(o => o.User)
|
||||
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);
|
||||
|
||||
if (order == null)
|
||||
{
|
||||
_logger.LogWarning("Order not found: {OrderId}", request.OrderId);
|
||||
throw new NotFoundException(nameof(UserOrder), request.OrderId);
|
||||
}
|
||||
|
||||
// 2. بررسی اینکه سفارش قبلاً پرداخت نشده باشد
|
||||
if (order.PaymentStatus == PaymentStatus.Success)
|
||||
{
|
||||
_logger.LogWarning("Order {OrderId} is already paid", request.OrderId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Verify با درگاه بانکی
|
||||
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
|
||||
request.Authority,
|
||||
request.Authority // verificationToken - در بعضی درگاهها همان Authority است
|
||||
);
|
||||
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Payment verification failed for OrderId {OrderId}: {Message}",
|
||||
request.OrderId,
|
||||
verifyResult.Message
|
||||
);
|
||||
|
||||
order.PaymentStatus = PaymentStatus.Reject;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
throw new ValidationException($"تراکنش ناموفق: {verifyResult.Message}");
|
||||
}
|
||||
|
||||
// 4. شارژ کیف پول کاربر
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
|
||||
|
||||
if (wallet == null)
|
||||
{
|
||||
_logger.LogError("Wallet not found for UserId: {UserId}", order.UserId);
|
||||
throw new NotFoundException($"کیف پول کاربر با شناسه {order.UserId} یافت نشد");
|
||||
}
|
||||
|
||||
// شارژ Balance (موجودی عادی)
|
||||
var oldBalance = wallet.Balance;
|
||||
wallet.Balance += order.Amount;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Charging Balance for UserId {UserId}: {OldBalance} -> {NewBalance}",
|
||||
order.UserId,
|
||||
oldBalance,
|
||||
wallet.Balance
|
||||
);
|
||||
|
||||
// شارژ DiscountBalance (موجودی تخفیف)
|
||||
var oldDiscountBalance = wallet.DiscountBalance;
|
||||
wallet.DiscountBalance += order.Amount;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Charging DiscountBalance for UserId {UserId}: {OldBalance} -> {NewBalance}",
|
||||
order.UserId,
|
||||
oldDiscountBalance,
|
||||
wallet.DiscountBalance
|
||||
);
|
||||
|
||||
// 5. ثبت Transaction
|
||||
var transaction = new Transactions
|
||||
{
|
||||
Amount = order.Amount,
|
||||
Description = $"خرید پکیج طلایی از درگاه - سفارش #{order.Id}",
|
||||
PaymentStatus = PaymentStatus.Success,
|
||||
PaymentDate = DateTime.UtcNow,
|
||||
RefId = verifyResult.RefId,
|
||||
Type = TransactionType.DepositIpg
|
||||
};
|
||||
|
||||
_context.Transactionss.Add(transaction);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. ثبت لاگ تغییر Balance
|
||||
var balanceLog = new UserWalletChangeLog
|
||||
{
|
||||
WalletId = wallet.Id,
|
||||
CurrentBalance = wallet.Balance,
|
||||
ChangeValue = order.Amount,
|
||||
CurrentNetworkBalance = wallet.NetworkBalance,
|
||||
ChangeNerworkValue = 0,
|
||||
CurrentDiscountBalance = wallet.DiscountBalance - order.Amount, // قبل از شارژ DiscountBalance
|
||||
ChangeDiscountValue = 0,
|
||||
IsIncrease = true,
|
||||
RefrenceId = transaction.Id
|
||||
};
|
||||
await _context.UserWalletChangeLogs.AddAsync(balanceLog, cancellationToken);
|
||||
|
||||
// 7. ثبت لاگ تغییر DiscountBalance
|
||||
var discountLog = new UserWalletChangeLog
|
||||
{
|
||||
WalletId = wallet.Id,
|
||||
CurrentBalance = wallet.Balance,
|
||||
ChangeValue = 0,
|
||||
CurrentNetworkBalance = wallet.NetworkBalance,
|
||||
ChangeNerworkValue = 0,
|
||||
CurrentDiscountBalance = wallet.DiscountBalance,
|
||||
ChangeDiscountValue = order.Amount,
|
||||
IsIncrease = true,
|
||||
RefrenceId = transaction.Id
|
||||
};
|
||||
await _context.UserWalletChangeLogs.AddAsync(discountLog, cancellationToken);
|
||||
|
||||
// 8. بهروزرسانی Order
|
||||
order.TransactionId = transaction.Id;
|
||||
order.PaymentStatus = PaymentStatus.Success;
|
||||
order.PaymentDate = DateTime.UtcNow;
|
||||
order.PaymentMethod = PaymentMethod.IPG;
|
||||
|
||||
// 9. تغییر User.PackagePurchaseMethod
|
||||
order.User.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Golden package purchase verified successfully. " +
|
||||
"OrderId: {OrderId}, UserId: {UserId}, TransactionId: {TransactionId}, RefId: {RefId}",
|
||||
order.Id,
|
||||
order.UserId,
|
||||
transaction.Id,
|
||||
verifyResult.RefId
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error in VerifyGoldenPackagePurchaseCommand. OrderId: {OrderId}",
|
||||
request.OrderId
|
||||
);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.Commands.RefundTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// Command برای استرداد تراکنش
|
||||
/// </summary>
|
||||
public record RefundTransactionCommand : IRequest<RefundTransactionResponseDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه تراکنش برای استرداد
|
||||
/// </summary>
|
||||
public long TransactionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// دلیل استرداد
|
||||
/// </summary>
|
||||
public string RefundReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ استرداد (اگر null باشد، کل مبلغ استرداد میشود)
|
||||
/// </summary>
|
||||
public long? RefundAmount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using CMSMicroservice.Domain.Events;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.Commands.RefundTransaction;
|
||||
|
||||
public class RefundTransactionCommandHandler : IRequestHandler<RefundTransactionCommand, RefundTransactionResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public RefundTransactionCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<RefundTransactionResponseDto> Handle(RefundTransactionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// پیدا کردن تراکنش اصلی
|
||||
var originalTransaction = await _context.Transactionss
|
||||
.FirstOrDefaultAsync(t => t.Id == request.TransactionId, cancellationToken);
|
||||
|
||||
if (originalTransaction == null)
|
||||
{
|
||||
throw new NotFoundException(nameof(Transactions), request.TransactionId);
|
||||
}
|
||||
|
||||
// چک کردن که تراکنش Success باشد
|
||||
if (originalTransaction.PaymentStatus != PaymentStatus.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"فقط تراکنشهای موفق قابل استرداد هستند. وضعیت فعلی: {originalTransaction.PaymentStatus}");
|
||||
}
|
||||
|
||||
// محاسبه مبلغ استرداد
|
||||
var refundAmount = request.RefundAmount ?? originalTransaction.Amount;
|
||||
|
||||
if (refundAmount > originalTransaction.Amount)
|
||||
{
|
||||
throw new InvalidOperationException("مبلغ استرداد نمیتواند بیشتر از مبلغ اصلی باشد");
|
||||
}
|
||||
|
||||
// ایجاد تراکنش استرداد جدید
|
||||
var refundTransaction = new Transactions
|
||||
{
|
||||
Amount = -refundAmount, // مبلغ منفی برای استرداد
|
||||
Description = $"استرداد تراکنش {request.TransactionId}: {request.RefundReason}",
|
||||
PaymentStatus = PaymentStatus.Success,
|
||||
PaymentDate = DateTime.UtcNow,
|
||||
RefId = $"REFUND-{originalTransaction.RefId}",
|
||||
Type = TransactionType.Buy // یا میتونیم یک نوع جدید برای Refund تعریف کنیم
|
||||
};
|
||||
|
||||
await _context.Transactionss.AddAsync(refundTransaction, cancellationToken);
|
||||
refundTransaction.AddDomainEvent(new RefundTransactionEvent(refundTransaction, originalTransaction));
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new RefundTransactionResponseDto
|
||||
{
|
||||
OriginalTransactionId = originalTransaction.Id,
|
||||
RefundTransactionId = refundTransaction.Id,
|
||||
RefundAmount = refundAmount,
|
||||
Message = "استرداد با موفقیت انجام شد"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.Commands.RefundTransaction;
|
||||
|
||||
public class RefundTransactionCommandValidator : AbstractValidator<RefundTransactionCommand>
|
||||
{
|
||||
public RefundTransactionCommandValidator()
|
||||
{
|
||||
RuleFor(v => v.TransactionId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه تراکنش باید بزرگتر از صفر باشد");
|
||||
|
||||
RuleFor(v => v.RefundReason)
|
||||
.NotEmpty()
|
||||
.WithMessage("دلیل استرداد الزامی است")
|
||||
.MaximumLength(500)
|
||||
.WithMessage("دلیل استرداد نباید بیش از 500 کاراکتر باشد");
|
||||
|
||||
RuleFor(v => v.RefundAmount)
|
||||
.GreaterThan(0)
|
||||
.When(v => v.RefundAmount.HasValue)
|
||||
.WithMessage("مبلغ استرداد باید بزرگتر از صفر باشد");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.Commands.RefundTransaction;
|
||||
|
||||
public class RefundTransactionResponseDto
|
||||
{
|
||||
public long OriginalTransactionId { get; set; }
|
||||
public long RefundTransactionId { get; set; }
|
||||
public long RefundAmount { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.Commands.VerifyTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// Command برای تایید پرداخت (Callback از درگاه)
|
||||
/// </summary>
|
||||
public record VerifyTransactionCommand : IRequest<VerifyTransactionResponseDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه تراکنش در سیستم
|
||||
/// </summary>
|
||||
public long TransactionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// کد رهگیری از درگاه پرداخت (RefId)
|
||||
/// </summary>
|
||||
public string RefId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// وضعیت پرداخت از درگاه
|
||||
/// </summary>
|
||||
public PaymentStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// تاریخ پرداخت
|
||||
/// </summary>
|
||||
public DateTime PaymentDate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using CMSMicroservice.Domain.Events;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.Commands.VerifyTransaction;
|
||||
|
||||
public class VerifyTransactionCommandHandler : IRequestHandler<VerifyTransactionCommand, VerifyTransactionResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public VerifyTransactionCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<VerifyTransactionResponseDto> Handle(VerifyTransactionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// پیدا کردن تراکنش
|
||||
var transaction = await _context.Transactionss
|
||||
.FirstOrDefaultAsync(t => t.Id == request.TransactionId, cancellationToken);
|
||||
|
||||
if (transaction == null)
|
||||
{
|
||||
throw new NotFoundException(nameof(Transactions), request.TransactionId);
|
||||
}
|
||||
|
||||
// چک کردن که تراکنش در وضعیت Pending باشد
|
||||
if (transaction.PaymentStatus != PaymentStatus.Pending)
|
||||
{
|
||||
throw new InvalidOperationException($"Transaction {request.TransactionId} is not in Pending status. Current status: {transaction.PaymentStatus}");
|
||||
}
|
||||
|
||||
// بهروزرسانی وضعیت تراکنش
|
||||
transaction.PaymentStatus = request.Status;
|
||||
transaction.RefId = request.RefId;
|
||||
transaction.PaymentDate = request.PaymentDate;
|
||||
|
||||
// ثبت Event
|
||||
transaction.AddDomainEvent(new VerifyTransactionEvent(transaction));
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new VerifyTransactionResponseDto
|
||||
{
|
||||
TransactionId = transaction.Id,
|
||||
Status = transaction.PaymentStatus,
|
||||
RefId = transaction.RefId,
|
||||
Message = transaction.PaymentStatus == PaymentStatus.Success
|
||||
? "پرداخت با موفقیت انجام شد"
|
||||
: "پرداخت ناموفق بود"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.Commands.VerifyTransaction;
|
||||
|
||||
public class VerifyTransactionCommandValidator : AbstractValidator<VerifyTransactionCommand>
|
||||
{
|
||||
public VerifyTransactionCommandValidator()
|
||||
{
|
||||
RuleFor(v => v.TransactionId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه تراکنش باید بزرگتر از صفر باشد");
|
||||
|
||||
RuleFor(v => v.RefId)
|
||||
.NotEmpty()
|
||||
.WithMessage("کد رهگیری الزامی است")
|
||||
.MaximumLength(100)
|
||||
.WithMessage("کد رهگیری نباید بیش از 100 کاراکتر باشد");
|
||||
|
||||
RuleFor(v => v.PaymentDate)
|
||||
.NotEmpty()
|
||||
.WithMessage("تاریخ پرداخت الزامی است");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.Commands.VerifyTransaction;
|
||||
|
||||
public class VerifyTransactionResponseDto
|
||||
{
|
||||
public long TransactionId { get; set; }
|
||||
public PaymentStatus Status { get; set; }
|
||||
public string RefId { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Domain.Events;
|
||||
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.EventHandlers.RefundTransactionEventHandlers;
|
||||
|
||||
public class RefundTransactionEventHandler : INotificationHandler<RefundTransactionEvent>
|
||||
{
|
||||
private readonly ILogger<RefundTransactionEventHandler> _logger;
|
||||
|
||||
public RefundTransactionEventHandler(ILogger<RefundTransactionEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task Handle(RefundTransactionEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Transaction {OriginalId} refunded with new transaction {RefundId}. Amount: {Amount}",
|
||||
notification.OriginalTransaction.Id,
|
||||
notification.RefundTransaction.Id,
|
||||
notification.RefundTransaction.Amount);
|
||||
|
||||
// اینجا میتونیم اعلان به کاربر بفرستیم یا کارهای دیگه انجام بدیم
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Domain.Events;
|
||||
|
||||
namespace CMSMicroservice.Application.TransactionsCQ.EventHandlers.VerifyTransactionEventHandlers;
|
||||
|
||||
public class VerifyTransactionEventHandler : INotificationHandler<VerifyTransactionEvent>
|
||||
{
|
||||
private readonly ILogger<VerifyTransactionEventHandler> _logger;
|
||||
|
||||
public VerifyTransactionEventHandler(ILogger<VerifyTransactionEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task Handle(VerifyTransactionEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Transaction {TransactionId} verified with status {Status} and RefId {RefId}",
|
||||
notification.Item.Id,
|
||||
notification.Item.PaymentStatus,
|
||||
notification.Item.RefId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ public record CreateNewUserCommand : IRequest<CreateNewUserResponseDto>
|
||||
public string? LastName { get; init; }
|
||||
//شماره موبایل
|
||||
public string Mobile { get; init; }
|
||||
//ایمیل
|
||||
public string? Email { get; init; }
|
||||
//کد ملی
|
||||
public string? NationalCode { get; init; }
|
||||
//آدرس آواتار
|
||||
|
||||
@@ -7,6 +7,8 @@ public record UpdateUserCommand : IRequest<Unit>
|
||||
public string? FirstName { get; init; }
|
||||
//نام خانوادگی
|
||||
public string? LastName { get; init; }
|
||||
//ایمیل
|
||||
public string? Email { get; init; }
|
||||
//کد ملی
|
||||
public string? NationalCode { get; init; }
|
||||
//آدرس آواتار
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace CMSMicroservice.Application.UserCartsCQ.Commands.ClearCart;
|
||||
|
||||
/// <summary>
|
||||
/// Command برای پاک کردن تمام سبد خرید کاربر
|
||||
/// </summary>
|
||||
public record ClearCartCommand : IRequest<ClearCartResponseDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using CMSMicroservice.Domain.Events;
|
||||
|
||||
namespace CMSMicroservice.Application.UserCartsCQ.Commands.ClearCart;
|
||||
|
||||
public class ClearCartCommandHandler : IRequestHandler<ClearCartCommand, ClearCartResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public ClearCartCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<ClearCartResponseDto> Handle(ClearCartCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// پیدا کردن تمام آیتمهای سبد خرید کاربر
|
||||
var cartItems = await _context.UserCartss
|
||||
.Where(c => c.UserId == request.UserId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (!cartItems.Any())
|
||||
{
|
||||
return new ClearCartResponseDto
|
||||
{
|
||||
UserId = request.UserId,
|
||||
RemovedItemsCount = 0,
|
||||
Message = "سبد خرید خالی است"
|
||||
};
|
||||
}
|
||||
|
||||
var itemsCount = cartItems.Count;
|
||||
|
||||
// حذف تمام آیتمها
|
||||
_context.UserCartss.RemoveRange(cartItems);
|
||||
|
||||
// ثبت Event
|
||||
// میتونیم یک Event برای هر آیتم یا یک Event کلی بفرستیم
|
||||
foreach (var item in cartItems)
|
||||
{
|
||||
item.AddDomainEvent(new ClearCartEvent(item));
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new ClearCartResponseDto
|
||||
{
|
||||
UserId = request.UserId,
|
||||
RemovedItemsCount = itemsCount,
|
||||
Message = $"{itemsCount} آیتم از سبد خرید حذف شد"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace CMSMicroservice.Application.UserCartsCQ.Commands.ClearCart;
|
||||
|
||||
public class ClearCartCommandValidator : AbstractValidator<ClearCartCommand>
|
||||
{
|
||||
public ClearCartCommandValidator()
|
||||
{
|
||||
RuleFor(v => v.UserId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace CMSMicroservice.Application.UserCartsCQ.Commands.ClearCart;
|
||||
|
||||
public class ClearCartResponseDto
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public int RemovedItemsCount { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Domain.Events;
|
||||
|
||||
namespace CMSMicroservice.Application.UserCartsCQ.EventHandlers.ClearCartEventHandlers;
|
||||
|
||||
public class ClearCartEventHandler : INotificationHandler<ClearCartEvent>
|
||||
{
|
||||
private readonly ILogger<ClearCartEventHandler> _logger;
|
||||
|
||||
public ClearCartEventHandler(ILogger<ClearCartEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task Handle(ClearCartEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Cart item {CartId} removed for user {UserId}",
|
||||
notification.Item.Id,
|
||||
notification.Item.UserId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.UserOrderCQ.Commands.CancelOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Command برای لغو سفارش
|
||||
/// </summary>
|
||||
public record CancelOrderCommand : IRequest<CancelOrderResponseDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه سفارش
|
||||
/// </summary>
|
||||
public long OrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// دلیل لغو سفارش
|
||||
/// </summary>
|
||||
public string CancelReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// آیا مبلغ باید بازگردانده شود؟
|
||||
/// </summary>
|
||||
public bool RefundPayment { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using CMSMicroservice.Domain.Events;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.UserOrderCQ.Commands.CancelOrder;
|
||||
|
||||
public class CancelOrderCommandHandler : IRequestHandler<CancelOrderCommand, CancelOrderResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public CancelOrderCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<CancelOrderResponseDto> Handle(CancelOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// پیدا کردن سفارش
|
||||
var order = await _context.UserOrders
|
||||
.Include(o => o.Transaction)
|
||||
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);
|
||||
|
||||
if (order == null)
|
||||
{
|
||||
throw new NotFoundException(nameof(UserOrder), request.OrderId);
|
||||
}
|
||||
|
||||
// چک کردن که سفارش قابل لغو باشد
|
||||
if (order.DeliveryStatus == DeliveryStatus.Delivered)
|
||||
{
|
||||
throw new InvalidOperationException("سفارش تحویل داده شده قابل لغو نیست");
|
||||
}
|
||||
|
||||
if (order.DeliveryStatus == DeliveryStatus.Cancelled)
|
||||
{
|
||||
throw new InvalidOperationException("این سفارش قبلاً لغو شده است");
|
||||
}
|
||||
|
||||
// تغییر وضعیت سفارش
|
||||
order.DeliveryStatus = DeliveryStatus.Cancelled;
|
||||
order.DeliveryDescription = $"لغو شده: {request.CancelReason}";
|
||||
|
||||
// اگر درخواست بازگشت پول داریم و پرداخت موفق بوده
|
||||
if (request.RefundPayment &&
|
||||
order.Transaction != null &&
|
||||
order.Transaction.PaymentStatus == PaymentStatus.Success)
|
||||
{
|
||||
// ایجاد تراکنش استرداد
|
||||
var refundTransaction = new Transactions
|
||||
{
|
||||
Amount = -order.Amount,
|
||||
Description = $"بازگشت وجه سفارش {request.OrderId}: {request.CancelReason}",
|
||||
PaymentStatus = PaymentStatus.Success,
|
||||
PaymentDate = DateTime.UtcNow,
|
||||
RefId = $"REFUND-ORDER-{order.Id}",
|
||||
Type = TransactionType.Buy
|
||||
};
|
||||
|
||||
await _context.Transactionss.AddAsync(refundTransaction, cancellationToken);
|
||||
}
|
||||
|
||||
// ثبت Event
|
||||
order.AddDomainEvent(new CancelOrderEvent(order, request.CancelReason));
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new CancelOrderResponseDto
|
||||
{
|
||||
OrderId = order.Id,
|
||||
Status = order.DeliveryStatus,
|
||||
Message = "سفارش با موفقیت لغو شد",
|
||||
RefundProcessed = request.RefundPayment && order.Transaction != null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace CMSMicroservice.Application.UserOrderCQ.Commands.CancelOrder;
|
||||
|
||||
public class CancelOrderCommandValidator : AbstractValidator<CancelOrderCommand>
|
||||
{
|
||||
public CancelOrderCommandValidator()
|
||||
{
|
||||
RuleFor(v => v.OrderId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه سفارش باید بزرگتر از صفر باشد");
|
||||
|
||||
RuleFor(v => v.CancelReason)
|
||||
.NotEmpty()
|
||||
.WithMessage("دلیل لغو سفارش الزامی است")
|
||||
.MaximumLength(500)
|
||||
.WithMessage("دلیل لغو نباید بیش از 500 کاراکتر باشد");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.UserOrderCQ.Commands.CancelOrder;
|
||||
|
||||
public class CancelOrderResponseDto
|
||||
{
|
||||
public long OrderId { get; set; }
|
||||
public DeliveryStatus Status { get; set; }
|
||||
public string Message { get; set; }
|
||||
public bool RefundProcessed { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Domain.Events;
|
||||
|
||||
namespace CMSMicroservice.Application.UserOrderCQ.EventHandlers.CancelOrderEventHandlers;
|
||||
|
||||
public class CancelOrderEventHandler : INotificationHandler<CancelOrderEvent>
|
||||
{
|
||||
private readonly ILogger<CancelOrderEventHandler> _logger;
|
||||
|
||||
public CancelOrderEventHandler(ILogger<CancelOrderEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task Handle(CancelOrderEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Order {OrderId} cancelled. Reason: {Reason}",
|
||||
notification.Order.Id,
|
||||
notification.CancelReason);
|
||||
|
||||
// اینجا میتونیم اعلان به کاربر بفرستیم
|
||||
// یا موجودی محصولات رو بازگردانیم به انبار
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.WalletCQ.Commands.ChargeDiscountWallet;
|
||||
|
||||
/// <summary>
|
||||
/// دستور شارژ کیف پول تخفیفی از طریق درگاه
|
||||
/// </summary>
|
||||
public class ChargeDiscountWalletCommand : IRequest<PaymentInitiateResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ مورد نظر برای شارژ (ریال)
|
||||
/// </summary>
|
||||
public long Amount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.WalletCQ.Commands.ChargeDiscountWallet;
|
||||
|
||||
public class ChargeDiscountWalletCommandHandler
|
||||
: IRequestHandler<ChargeDiscountWalletCommand, PaymentInitiateResult>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
private readonly ILogger<ChargeDiscountWalletCommandHandler> _logger;
|
||||
|
||||
public ChargeDiscountWalletCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IPaymentGatewayService paymentGateway,
|
||||
ILogger<ChargeDiscountWalletCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_paymentGateway = paymentGateway;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PaymentInitiateResult> Handle(
|
||||
ChargeDiscountWalletCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Charging discount wallet for UserId: {UserId}, Amount: {Amount}",
|
||||
request.UserId,
|
||||
request.Amount
|
||||
);
|
||||
|
||||
// 1. بررسی وجود کاربر
|
||||
var user = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("User not found: {UserId}", request.UserId);
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
}
|
||||
|
||||
// 2. بررسی وجود کیف پول
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken);
|
||||
|
||||
if (wallet == null)
|
||||
{
|
||||
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
|
||||
throw new NotFoundException("کیف پول کاربر یافت نشد");
|
||||
}
|
||||
|
||||
// 3. ایجاد درخواست پرداخت
|
||||
var paymentRequest = new PaymentRequest
|
||||
{
|
||||
Amount = request.Amount,
|
||||
UserId = user.Id,
|
||||
Mobile = user.Mobile ?? "",
|
||||
CallbackUrl = $"https://yourdomain.com/api/wallet/verify-discount-charge",
|
||||
Description = $"شارژ کیف پول تخفیفی - کاربر {user.Id}"
|
||||
};
|
||||
|
||||
var paymentResult = await _paymentGateway.InitiatePaymentAsync(paymentRequest);
|
||||
|
||||
if (!paymentResult.IsSuccess)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Payment gateway failed for UserId {UserId}: {ErrorMessage}",
|
||||
user.Id,
|
||||
paymentResult.ErrorMessage
|
||||
);
|
||||
|
||||
throw new Exception($"خطا در ارتباط با درگاه پرداخت: {paymentResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discount wallet charge initiated. UserId: {UserId}, RefId: {RefId}",
|
||||
user.Id,
|
||||
paymentResult.RefId
|
||||
);
|
||||
|
||||
return paymentResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error in ChargeDiscountWalletCommand for UserId: {UserId}",
|
||||
request.UserId
|
||||
);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace CMSMicroservice.Application.WalletCQ.Commands.ChargeDiscountWallet;
|
||||
|
||||
public class ChargeDiscountWalletCommandValidator : AbstractValidator<ChargeDiscountWalletCommand>
|
||||
{
|
||||
public ChargeDiscountWalletCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
|
||||
|
||||
RuleFor(x => x.Amount)
|
||||
.GreaterThanOrEqualTo(10_000)
|
||||
.WithMessage("حداقل مبلغ شارژ 10,000 تومان است")
|
||||
.LessThanOrEqualTo(1_000_000_000)
|
||||
.WithMessage("حداکثر مبلغ شارژ 1,000,000,000 تومان است");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.WalletCQ.Commands.VerifyDiscountWalletCharge;
|
||||
|
||||
/// <summary>
|
||||
/// دستور تأیید شارژ کیف پول تخفیفی
|
||||
/// </summary>
|
||||
public class VerifyDiscountWalletChargeCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ
|
||||
/// </summary>
|
||||
public long Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// کد Authority از درگاه
|
||||
/// </summary>
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.WalletCQ.Commands.VerifyDiscountWalletCharge;
|
||||
|
||||
public class VerifyDiscountWalletChargeCommandHandler
|
||||
: IRequestHandler<VerifyDiscountWalletChargeCommand, bool>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
private readonly ILogger<VerifyDiscountWalletChargeCommandHandler> _logger;
|
||||
|
||||
public VerifyDiscountWalletChargeCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IPaymentGatewayService paymentGateway,
|
||||
ILogger<VerifyDiscountWalletChargeCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_paymentGateway = paymentGateway;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
VerifyDiscountWalletChargeCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Verifying discount wallet charge. UserId: {UserId}, Amount: {Amount}, Authority: {Authority}",
|
||||
request.UserId,
|
||||
request.Amount,
|
||||
request.Authority
|
||||
);
|
||||
|
||||
// 1. بررسی کاربر
|
||||
var user = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("User not found: {UserId}", request.UserId);
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
}
|
||||
|
||||
// 2. Verify با درگاه
|
||||
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
|
||||
request.Authority,
|
||||
request.Authority // verificationToken - در بعضی درگاهها مثل زرینپال همان Authority است
|
||||
);
|
||||
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Discount wallet charge verification failed for UserId {UserId}: {Message}",
|
||||
request.UserId,
|
||||
verifyResult.Message
|
||||
);
|
||||
|
||||
throw new Exception($"تراکنش ناموفق: {verifyResult.Message}");
|
||||
}
|
||||
|
||||
// 3. شارژ DiscountBalance
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
|
||||
|
||||
if (wallet == null)
|
||||
{
|
||||
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
|
||||
throw new NotFoundException($"کیف پول کاربر با شناسه {request.UserId} یافت نشد");
|
||||
}
|
||||
|
||||
var oldBalance = wallet.DiscountBalance;
|
||||
wallet.DiscountBalance += request.Amount;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Charging discount balance for UserId {UserId}: {OldBalance} -> {NewBalance}",
|
||||
request.UserId,
|
||||
oldBalance,
|
||||
wallet.DiscountBalance
|
||||
);
|
||||
|
||||
// 4. ثبت Transaction
|
||||
var transaction = new Transactions
|
||||
{
|
||||
Amount = request.Amount,
|
||||
Description = $"شارژ کیف پول تخفیفی - کاربر {user.Id}",
|
||||
PaymentStatus = PaymentStatus.Success,
|
||||
PaymentDate = DateTime.UtcNow,
|
||||
RefId = verifyResult.RefId,
|
||||
Type = TransactionType.DiscountWalletCharge
|
||||
};
|
||||
|
||||
_context.Transactionss.Add(transaction);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discount wallet charged successfully. UserId: {UserId}, TransactionId: {TransactionId}, RefId: {RefId}",
|
||||
user.Id,
|
||||
transaction.Id,
|
||||
verifyResult.RefId
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error in VerifyDiscountWalletChargeCommand for UserId: {UserId}",
|
||||
request.UserId
|
||||
);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,11 @@ public class ClubMembership : BaseAuditableEntity
|
||||
/// </summary>
|
||||
public long TotalEarned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// نحوه خرید پکیج که منجر به فعالسازی باشگاه شد
|
||||
/// </summary>
|
||||
public PackagePurchaseMethod PurchaseMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// UserClubFeature Collection Navigation Reference
|
||||
/// </summary>
|
||||
|
||||
@@ -85,6 +85,21 @@ public class UserCommissionPayout : BaseAuditableEntity
|
||||
/// </summary>
|
||||
public string? RejectionReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره مرجع بانک (بعد از واریز موفق)
|
||||
/// </summary>
|
||||
public string? BankReferenceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// کد پیگیری بانکی
|
||||
/// </summary>
|
||||
public string? BankTrackingCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// دلیل خطا در پرداخت (اگر ناموفق باشد)
|
||||
/// </summary>
|
||||
public string? PaymentFailureReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CommissionPayoutHistory Collection Navigation Reference
|
||||
/// </summary>
|
||||
|
||||
59
src/CMSMicroservice.Domain/Entities/DayaLoanContract.cs
Normal file
59
src/CMSMicroservice.Domain/Entities/DayaLoanContract.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// قرارداد وام دایا
|
||||
/// </summary>
|
||||
public class DayaLoanContract : BaseAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User Navigation Property
|
||||
/// </summary>
|
||||
public virtual User User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// کد ملی
|
||||
/// </summary>
|
||||
public string NationalCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره قرارداد دایا
|
||||
/// </summary>
|
||||
public string? ContractNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// وضعیت وام
|
||||
/// </summary>
|
||||
public DayaLoanStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// آیا پردازش شده است؟ (شارژ کیف پول انجام شده)
|
||||
/// </summary>
|
||||
public bool IsProcessed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تاریخ آخرین استعلام
|
||||
/// </summary>
|
||||
public DateTime? LastCheckDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تاریخ پردازش
|
||||
/// </summary>
|
||||
public DateTime? ProcessedDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه تراکنش (بعد از پردازش)
|
||||
/// </summary>
|
||||
public long? TransactionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Transaction Navigation Property
|
||||
/// </summary>
|
||||
public virtual Transactions? Transaction { get; set; }
|
||||
}
|
||||
@@ -8,6 +8,8 @@ public class User : BaseAuditableEntity
|
||||
public string? LastName { get; set; }
|
||||
//شماره موبایل
|
||||
public string Mobile { get; set; }
|
||||
//ایمیل
|
||||
public string? Email { get; set; }
|
||||
//کد ملی
|
||||
public string? NationalCode { get; set; }
|
||||
//آدرس آواتار
|
||||
@@ -54,6 +56,21 @@ public class User : BaseAuditableEntity
|
||||
/// </summary>
|
||||
public NetworkLeg? LegPosition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// آیا اعتبار دایا را دریافت کرده است؟
|
||||
/// </summary>
|
||||
public bool HasReceivedDayaCredit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تاریخ دریافت اعتبار دایا
|
||||
/// </summary>
|
||||
public DateTime? DayaCreditReceivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// نحوه خرید پکیج طلایی (برای جلوگیری از خرید مجدد)
|
||||
/// </summary>
|
||||
public PackagePurchaseMethod PackagePurchaseMethod { get; set; } = PackagePurchaseMethod.None;
|
||||
|
||||
// ============= Navigation Properties =============
|
||||
|
||||
//UserAddress Collection Navigation Reference
|
||||
@@ -80,4 +97,6 @@ public class User : BaseAuditableEntity
|
||||
public virtual ICollection<NetworkWeeklyBalance>? NetworkWeeklyBalances { get; set; }
|
||||
//UserCommissionPayout Collection Navigation Reference
|
||||
public virtual ICollection<UserCommissionPayout>? CommissionPayouts { get; set; }
|
||||
//DayaLoanContract Collection Navigation Reference
|
||||
public virtual ICollection<DayaLoanContract>? DayaLoanContracts { get; set; }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ public class UserWalletChangeLog : BaseAuditableEntity
|
||||
public long CurrentNetworkBalance { get; set; }
|
||||
//تغییر موجودی شبکه
|
||||
public long ChangeNerworkValue { get; set; }
|
||||
//موجودی جاری تخفیف
|
||||
public long CurrentDiscountBalance { get; set; }
|
||||
//تغییر موجودی تخفیف
|
||||
public long ChangeDiscountValue { get; set; }
|
||||
//افزایشی؟
|
||||
public bool IsIncrease { get; set; }
|
||||
//شناسه ارجاع
|
||||
|
||||
@@ -25,8 +25,13 @@ public enum CommissionPayoutStatus
|
||||
/// </summary>
|
||||
Withdrawn = 3,
|
||||
|
||||
/// <summary>
|
||||
/// خطا در پرداخت بانکی
|
||||
/// </summary>
|
||||
PaymentFailed = 4,
|
||||
|
||||
/// <summary>
|
||||
/// لغو شده
|
||||
/// </summary>
|
||||
Cancelled = 4
|
||||
Cancelled = 5
|
||||
}
|
||||
|
||||
22
src/CMSMicroservice.Domain/Enums/DayaLoanStatus.cs
Normal file
22
src/CMSMicroservice.Domain/Enums/DayaLoanStatus.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace CMSMicroservice.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// وضعیت وام دایا
|
||||
/// </summary>
|
||||
public enum DayaLoanStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// در انتظار دریافت وام (خرید انجام شده، قرارداد امضا شده، درخواست وام ثبت شده)
|
||||
/// </summary>
|
||||
PendingReceive = 0,
|
||||
|
||||
/// <summary>
|
||||
/// وام دریافت شده (در آینده اضافه میشود)
|
||||
/// </summary>
|
||||
Received = 1,
|
||||
|
||||
/// <summary>
|
||||
/// رد شده (در آینده اضافه میشود)
|
||||
/// </summary>
|
||||
Rejected = 2,
|
||||
}
|
||||
@@ -13,5 +13,7 @@ public enum DeliveryStatus
|
||||
Delivered = 3,
|
||||
// مرجوع شده
|
||||
Returned = 4,
|
||||
// لغو شده
|
||||
Cancelled = 5,
|
||||
}
|
||||
|
||||
|
||||
22
src/CMSMicroservice.Domain/Enums/PackagePurchaseMethod.cs
Normal file
22
src/CMSMicroservice.Domain/Enums/PackagePurchaseMethod.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace CMSMicroservice.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// نحوه خرید پکیج طلایی توسط کاربر
|
||||
/// </summary>
|
||||
public enum PackagePurchaseMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// هنوز پکیج خریداری نکرده
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// از طریق وام دایا
|
||||
/// </summary>
|
||||
DayaLoan = 1,
|
||||
|
||||
/// <summary>
|
||||
/// از طریق پرداخت مستقیم درگاه بانکی
|
||||
/// </summary>
|
||||
DirectPurchase = 2
|
||||
}
|
||||
13
src/CMSMicroservice.Domain/Events/CancelOrderEvent.cs
Normal file
13
src/CMSMicroservice.Domain/Events/CancelOrderEvent.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace CMSMicroservice.Domain.Events;
|
||||
|
||||
public class CancelOrderEvent : BaseEvent
|
||||
{
|
||||
public CancelOrderEvent(UserOrder order, string reason)
|
||||
{
|
||||
Order = order;
|
||||
CancelReason = reason;
|
||||
}
|
||||
|
||||
public UserOrder Order { get; }
|
||||
public string CancelReason { get; }
|
||||
}
|
||||
11
src/CMSMicroservice.Domain/Events/ClearCartEvent.cs
Normal file
11
src/CMSMicroservice.Domain/Events/ClearCartEvent.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace CMSMicroservice.Domain.Events;
|
||||
|
||||
public class ClearCartEvent : BaseEvent
|
||||
{
|
||||
public ClearCartEvent(UserCarts item)
|
||||
{
|
||||
Item = item;
|
||||
}
|
||||
|
||||
public UserCarts Item { get; }
|
||||
}
|
||||
15
src/CMSMicroservice.Domain/Events/DayaLoanApprovedEvent.cs
Normal file
15
src/CMSMicroservice.Domain/Events/DayaLoanApprovedEvent.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace CMSMicroservice.Domain.Events;
|
||||
|
||||
public class DayaLoanApprovedEvent : BaseEvent
|
||||
{
|
||||
public DayaLoanApprovedEvent(User user, Transactions transaction, string contractNumber)
|
||||
{
|
||||
User = user;
|
||||
Transaction = transaction;
|
||||
ContractNumber = contractNumber;
|
||||
}
|
||||
|
||||
public User User { get; }
|
||||
public Transactions Transaction { get; }
|
||||
public string ContractNumber { get; }
|
||||
}
|
||||
13
src/CMSMicroservice.Domain/Events/RefundTransactionEvent.cs
Normal file
13
src/CMSMicroservice.Domain/Events/RefundTransactionEvent.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace CMSMicroservice.Domain.Events;
|
||||
|
||||
public class RefundTransactionEvent : BaseEvent
|
||||
{
|
||||
public RefundTransactionEvent(Transactions refundTransaction, Transactions originalTransaction)
|
||||
{
|
||||
RefundTransaction = refundTransaction;
|
||||
OriginalTransaction = originalTransaction;
|
||||
}
|
||||
|
||||
public Transactions RefundTransaction { get; }
|
||||
public Transactions OriginalTransaction { get; }
|
||||
}
|
||||
11
src/CMSMicroservice.Domain/Events/VerifyTransactionEvent.cs
Normal file
11
src/CMSMicroservice.Domain/Events/VerifyTransactionEvent.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace CMSMicroservice.Domain.Events;
|
||||
|
||||
public class VerifyTransactionEvent : BaseEvent
|
||||
{
|
||||
public VerifyTransactionEvent(Transactions item)
|
||||
{
|
||||
Item = item;
|
||||
}
|
||||
|
||||
public Transactions Item { get; }
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.DayaLoanCQ.Services;
|
||||
using CMSMicroservice.Infrastructure.Persistence;
|
||||
using CMSMicroservice.Infrastructure.Persistence.Interceptors;
|
||||
using CMSMicroservice.Infrastructure.BackgroundJobs;
|
||||
using CMSMicroservice.Infrastructure.Services.Monitoring;
|
||||
using CMSMicroservice.Infrastructure.Configuration;
|
||||
using CMSMicroservice.Infrastructure.Services.Payment;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
@@ -30,6 +32,37 @@ public static class ConfigureServices
|
||||
services.AddScoped<INetworkPlacementService, NetworkPlacementService>();
|
||||
services.AddScoped<IAlertService, AlertService>();
|
||||
services.AddScoped<IUserNotificationService, UserNotificationService>();
|
||||
services.AddScoped<IDayaLoanApiService, MockDayaLoanApiService>(); // Mock - جایگزین با Real برای Production
|
||||
|
||||
// Payment Gateway Service - برای Development از Mock استفاده میشود
|
||||
// برای Production یکی از سرویسهای واقعی را فعال کنید
|
||||
var useRealPaymentGateway = configuration.GetValue<bool>("UseRealPaymentGateway", false);
|
||||
|
||||
if (useRealPaymentGateway)
|
||||
{
|
||||
var paymentProvider = configuration.GetValue<string>("PaymentProvider", "BankMellat");
|
||||
|
||||
if (paymentProvider == "Daya")
|
||||
{
|
||||
services.AddHttpClient<IPaymentGatewayService, DayaPaymentService>()
|
||||
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
else if (paymentProvider == "BankMellat")
|
||||
{
|
||||
services.AddHttpClient<IPaymentGatewayService, BankMellatPaymentService>()
|
||||
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid PaymentProvider: {paymentProvider}. Valid values: Daya, BankMellat");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Mock برای Development و Testing
|
||||
services.AddScoped<IPaymentGatewayService, MockPaymentGatewayService>();
|
||||
}
|
||||
|
||||
services.AddScoped<IApplicationDbContext>(p => p.GetRequiredService<ApplicationDbContext>());
|
||||
|
||||
// Background Workers - Deprecated: Using Hangfire instead
|
||||
|
||||
@@ -64,6 +64,7 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
|
||||
public DbSet<UserOrder> UserOrders => Set<UserOrder>();
|
||||
public DbSet<UserWallet> UserWallets => Set<UserWallet>();
|
||||
public DbSet<UserWalletChangeLog> UserWalletChangeLogs => Set<UserWalletChangeLog>();
|
||||
public DbSet<DayaLoanContract> DayaLoanContracts => Set<DayaLoanContract>();
|
||||
|
||||
// ============= Network Club System DbSets =============
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdatePoolContributionPercent : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// تغییر درصد استخر از 10% به 20%
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE SystemConfigurations
|
||||
SET Value = '20',
|
||||
Description = N'درصد مشارکت در استخر هفتگی از کل فعالسازیهای جدید شبکه (20%)'
|
||||
WHERE [Key] = 'Commission.WeeklyPoolContributionPercent'
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// بازگشت به 10%
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE SystemConfigurations
|
||||
SET Value = '10',
|
||||
Description = N'درصد مشارکت در استخر هفتگی از تعادل کل (در صورت نیاز)'
|
||||
WHERE [Key] = 'Commission.WeeklyPoolContributionPercent'
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
2283
src/CMSMicroservice.Infrastructure/Persistence/Migrations/20251201172747_AddEmailToUser.Designer.cs
generated
Normal file
2283
src/CMSMicroservice.Infrastructure/Persistence/Migrations/20251201172747_AddEmailToUser.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEmailToUser : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Email",
|
||||
schema: "CMS",
|
||||
table: "Users",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Email",
|
||||
schema: "CMS",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
2365
src/CMSMicroservice.Infrastructure/Persistence/Migrations/20251201191716_AddDayaLoanIntegration.Designer.cs
generated
Normal file
2365
src/CMSMicroservice.Infrastructure/Persistence/Migrations/20251201191716_AddDayaLoanIntegration.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDayaLoanIntegration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "DayaCreditReceivedAt",
|
||||
schema: "CMS",
|
||||
table: "Users",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HasReceivedDayaCredit",
|
||||
schema: "CMS",
|
||||
table: "Users",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DayaLoanContracts",
|
||||
schema: "CMS",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
UserId = table.Column<long>(type: "bigint", nullable: false),
|
||||
NationalCode = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ContractNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
IsProcessed = table.Column<bool>(type: "bit", nullable: false),
|
||||
LastCheckDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ProcessedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
TransactionId = table.Column<long>(type: "bigint", nullable: true),
|
||||
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DayaLoanContracts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DayaLoanContracts_Transactionss_TransactionId",
|
||||
column: x => x.TransactionId,
|
||||
principalSchema: "CMS",
|
||||
principalTable: "Transactionss",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_DayaLoanContracts_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalSchema: "CMS",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DayaLoanContracts_TransactionId",
|
||||
schema: "CMS",
|
||||
table: "DayaLoanContracts",
|
||||
column: "TransactionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DayaLoanContracts_UserId",
|
||||
schema: "CMS",
|
||||
table: "DayaLoanContracts",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DayaLoanContracts",
|
||||
schema: "CMS");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DayaCreditReceivedAt",
|
||||
schema: "CMS",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HasReceivedDayaCredit",
|
||||
schema: "CMS",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPackagePurchaseMethod : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PackagePurchaseMethod",
|
||||
schema: "CMS",
|
||||
table: "Users",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BankReferenceId",
|
||||
schema: "CMS",
|
||||
table: "UserCommissionPayouts",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BankTrackingCode",
|
||||
schema: "CMS",
|
||||
table: "UserCommissionPayouts",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PaymentFailureReason",
|
||||
schema: "CMS",
|
||||
table: "UserCommissionPayouts",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PurchaseMethod",
|
||||
schema: "CMS",
|
||||
table: "ClubMemberships",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PackagePurchaseMethod",
|
||||
schema: "CMS",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BankReferenceId",
|
||||
schema: "CMS",
|
||||
table: "UserCommissionPayouts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BankTrackingCode",
|
||||
schema: "CMS",
|
||||
table: "UserCommissionPayouts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PaymentFailureReason",
|
||||
schema: "CMS",
|
||||
table: "UserCommissionPayouts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PurchaseMethod",
|
||||
schema: "CMS",
|
||||
table: "ClubMemberships");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDiscountBalanceToWalletChangeLog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "ChangeDiscountValue",
|
||||
schema: "CMS",
|
||||
table: "UserWalletChangeLogs",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "CurrentDiscountBalance",
|
||||
schema: "CMS",
|
||||
table: "UserWalletChangeLogs",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ChangeDiscountValue",
|
||||
schema: "CMS",
|
||||
table: "UserWalletChangeLogs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CurrentDiscountBalance",
|
||||
schema: "CMS",
|
||||
table: "UserWalletChangeLogs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,9 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
b.Property<string>("LastModifiedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("PurchaseMethod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("TotalEarned")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -239,6 +242,12 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
b.Property<int>("BalancesEarned")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("BankReferenceId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("BankTrackingCode")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -261,6 +270,9 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
b.Property<DateTime?>("PaidAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("PaymentFailureReason")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -543,6 +555,63 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Contracts", "CMS");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CMSMicroservice.Domain.Entities.DayaLoanContract", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContractNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsProcessed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastCheckDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("LastModified")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("LastModifiedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("NationalCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ProcessedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long?>("TransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TransactionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("DayaLoanContracts", "CMS");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CMSMicroservice.Domain.Entities.FactorDetails", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -1420,12 +1489,21 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DayaCreditReceivedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("EmailNotifications")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("HasReceivedDayaCredit")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("HashPassword")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -1463,6 +1541,9 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
b.Property<long?>("NetworkParentId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("PackagePurchaseMethod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long?>("ParentId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -1787,6 +1868,9 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("ChangeDiscountValue")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("ChangeNerworkValue")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -1802,6 +1886,9 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
b.Property<long>("CurrentBalance")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CurrentDiscountBalance")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CurrentNetworkBalance")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -1896,6 +1983,23 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("WeeklyPool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CMSMicroservice.Domain.Entities.DayaLoanContract", b =>
|
||||
{
|
||||
b.HasOne("CMSMicroservice.Domain.Entities.Transactions", "Transaction")
|
||||
.WithMany()
|
||||
.HasForeignKey("TransactionId");
|
||||
|
||||
b.HasOne("CMSMicroservice.Domain.Entities.User", "User")
|
||||
.WithMany("DayaLoanContracts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Transaction");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CMSMicroservice.Domain.Entities.FactorDetails", b =>
|
||||
{
|
||||
b.HasOne("CMSMicroservice.Domain.Entities.UserOrder", "Order")
|
||||
@@ -2236,6 +2340,8 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.Navigation("CommissionPayouts");
|
||||
|
||||
b.Navigation("DayaLoanContracts");
|
||||
|
||||
b.Navigation("NetworkChildren");
|
||||
|
||||
b.Navigation("NetworkWeeklyBalances");
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using CMSMicroservice.Application.DayaLoanCQ.Services;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Mock Implementation برای شبیهسازی Daya API
|
||||
/// این کلاس فقط برای تست و توسعه است و باید با Implementation واقعی جایگزین شود
|
||||
/// </summary>
|
||||
public class MockDayaLoanApiService : IDayaLoanApiService
|
||||
{
|
||||
private readonly ILogger<MockDayaLoanApiService> _logger;
|
||||
|
||||
public MockDayaLoanApiService(ILogger<MockDayaLoanApiService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<DayaLoanStatusResult>> CheckLoanStatusAsync(
|
||||
List<string> nationalCodes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogWarning("⚠️ Using MOCK Daya API Service - Replace with real implementation!");
|
||||
|
||||
// شبیهسازی تاخیر شبکه
|
||||
await Task.Delay(100, cancellationToken);
|
||||
|
||||
var results = new List<DayaLoanStatusResult>();
|
||||
|
||||
foreach (var nationalCode in nationalCodes)
|
||||
{
|
||||
// شبیهسازی: کدملیهایی که با 1 شروع میشوند وام گرفتهاند
|
||||
if (nationalCode.StartsWith("1"))
|
||||
{
|
||||
results.Add(new DayaLoanStatusResult
|
||||
{
|
||||
NationalCode = nationalCode,
|
||||
Status = DayaLoanStatus.PendingReceive,
|
||||
ContractNumber = $"MOCK-DAYA-{nationalCode}-{DateTime.Now.Ticks}"
|
||||
});
|
||||
}
|
||||
// شبیهسازی: کدملیهایی که با 2 شروع میشوند رد شدهاند
|
||||
else if (nationalCode.StartsWith("2"))
|
||||
{
|
||||
results.Add(new DayaLoanStatusResult
|
||||
{
|
||||
NationalCode = nationalCode,
|
||||
Status = DayaLoanStatus.Rejected,
|
||||
ContractNumber = null
|
||||
});
|
||||
}
|
||||
// بقیه: هنوز بررسی نشدهاند
|
||||
else
|
||||
{
|
||||
results.Add(new DayaLoanStatusResult
|
||||
{
|
||||
NationalCode = nationalCode,
|
||||
Status = DayaLoanStatus.PendingReceive,
|
||||
ContractNumber = null // هنوز قرارداد صادر نشده
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Mock Daya API returned {Count} results", results.Count);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Real Implementation برای API واقعی دایا
|
||||
/// TODO: این کلاس باید پیادهسازی شود زمانی که API دایا آماده شد
|
||||
/// </summary>
|
||||
public class DayaLoanApiService : IDayaLoanApiService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<DayaLoanApiService> _logger;
|
||||
|
||||
public DayaLoanApiService(HttpClient httpClient, ILogger<DayaLoanApiService> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<DayaLoanStatusResult>> CheckLoanStatusAsync(
|
||||
List<string> nationalCodes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: پیادهسازی واقعی API دایا
|
||||
// مثال:
|
||||
// var request = new DayaApiRequest { NationalCodes = nationalCodes };
|
||||
// var response = await _httpClient.PostAsJsonAsync("/api/loan/check", request, cancellationToken);
|
||||
// response.EnsureSuccessStatusCode();
|
||||
// var result = await response.Content.ReadFromJsonAsync<DayaApiResponse>(cancellationToken);
|
||||
// return MapToResults(result);
|
||||
|
||||
throw new NotImplementedException("Real Daya API is not implemented yet. Use MockDayaLoanApiService for testing.");
|
||||
}
|
||||
}
|
||||
@@ -70,11 +70,25 @@ public class UserNotificationService : IUserNotificationService
|
||||
|
||||
var formattedAmount = amount.ToString("N0", new System.Globalization.CultureInfo("fa-IR"));
|
||||
|
||||
// Send Email (TODO: User entity needs Email field)
|
||||
// if (_emailSettings.Enabled && !string.IsNullOrEmpty(user.Email))
|
||||
// {
|
||||
// await SendEmailAsync(...);
|
||||
// }
|
||||
// Send Email
|
||||
if (_emailSettings.Enabled && !string.IsNullOrEmpty(user.Email))
|
||||
{
|
||||
var emailSubject = $"واریز کمیسیون هفته {weekNumber}";
|
||||
var emailBody = "<div dir='rtl' style='font-family: Tahoma, Arial; text-align: right;'>" +
|
||||
$"<h2>سلام {userFullName}</h2>" +
|
||||
$"<p>کمیسیون هفته {weekNumber} شما به مبلغ <strong>{formattedAmount} ریال</strong> به کیف پول شما واریز شد.</p>" +
|
||||
"<p>از اعتماد شما سپاسگزاریم.</p>" +
|
||||
"<hr/>" +
|
||||
"<p style='color: #666; font-size: 12px;'>FourSat - سیستم مدیریت باشگاه مشتریان</p>" +
|
||||
"</div>";
|
||||
|
||||
await SendEmailAsync(
|
||||
toEmail: user.Email,
|
||||
toName: userFullName,
|
||||
subject: emailSubject,
|
||||
body: emailBody,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
// Send SMS
|
||||
if (_smsSettings.Enabled && !string.IsNullOrEmpty(user.Mobile))
|
||||
@@ -107,11 +121,25 @@ public class UserNotificationService : IUserNotificationService
|
||||
var userFullName = $"{user.FirstName} {user.LastName}".Trim();
|
||||
if (string.IsNullOrEmpty(userFullName)) userFullName = "کاربر عزیز";
|
||||
|
||||
// Send Email (TODO: User entity needs Email field)
|
||||
// if (_emailSettings.Enabled && !string.IsNullOrEmpty(user.Email))
|
||||
// {
|
||||
// await SendEmailAsync(...);
|
||||
// }
|
||||
// Send Email
|
||||
if (_emailSettings.Enabled && !string.IsNullOrEmpty(user.Email))
|
||||
{
|
||||
var emailSubject = "فعالسازی باشگاه مشتریان FourSat";
|
||||
var emailBody = "<div dir='rtl' style='font-family: Tahoma, Arial; text-align: right;'>" +
|
||||
$"<h2>تبریک {userFullName}!</h2>" +
|
||||
"<p>عضویت شما در <strong>باشگاه مشتریان FourSat</strong> با موفقیت فعال شد.</p>" +
|
||||
"<p>از این پس میتوانید از مزایای ویژه باشگاه بهرهمند شوید.</p>" +
|
||||
"<hr/>" +
|
||||
"<p style='color: #666; font-size: 12px;'>FourSat - سیستم مدیریت باشگاه مشتریان</p>" +
|
||||
"</div>";
|
||||
|
||||
await SendEmailAsync(
|
||||
toEmail: user.Email,
|
||||
toName: userFullName,
|
||||
subject: emailSubject,
|
||||
body: emailBody,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
// Send SMS
|
||||
if (_smsSettings.Enabled && !string.IsNullOrEmpty(user.Mobile))
|
||||
@@ -145,11 +173,35 @@ public class UserNotificationService : IUserNotificationService
|
||||
var userFullName = $"{user.FirstName} {user.LastName}".Trim();
|
||||
if (string.IsNullOrEmpty(userFullName)) userFullName = "کاربر عزیز";
|
||||
|
||||
// Send Email (TODO: User entity needs Email field)
|
||||
// if (_emailSettings.Enabled && !string.IsNullOrEmpty(user.Email))
|
||||
// {
|
||||
// await SendEmailAsync(...);
|
||||
// }
|
||||
// Send Email
|
||||
if (_emailSettings.Enabled && !string.IsNullOrEmpty(user.Email))
|
||||
{
|
||||
var emailSubject = "خطا در واریز کمیسیون";
|
||||
var emailBody = "<div dir='rtl' style='font-family: Tahoma, Arial; text-align: right;'>" +
|
||||
$"<h2>سلام {userFullName}</h2>" +
|
||||
"<p>متأسفانه در واریز کمیسیون شما خطایی رخ داده است:</p>" +
|
||||
$"<p style='color: red;'><strong>{errorMessage}</strong></p>" +
|
||||
"<p>لطفاً با پشتیبانی تماس بگیرید.</p>" +
|
||||
"<hr/>" +
|
||||
"<p style='color: #666; font-size: 12px;'>FourSat - سیستم مدیریت باشگاه مشتریان</p>" +
|
||||
"</div>";
|
||||
|
||||
await SendEmailAsync(
|
||||
toEmail: user.Email,
|
||||
toName: userFullName,
|
||||
subject: emailSubject,
|
||||
body: emailBody,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
// Send SMS
|
||||
if (_smsSettings.Enabled && !string.IsNullOrEmpty(user.Mobile))
|
||||
{
|
||||
await SendSmsAsync(
|
||||
phoneNumber: user.Mobile,
|
||||
message: $"خطا در واریز کمیسیون: {errorMessage}\nلطفاً با پشتیبانی تماس بگیرید.",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -43,6 +43,18 @@ service TransactionsContract
|
||||
|
||||
};
|
||||
};
|
||||
rpc VerifyTransaction(VerifyTransactionRequest) returns (VerifyTransactionResponse){
|
||||
option (google.api.http) = {
|
||||
post: "/VerifyTransaction"
|
||||
body: "*"
|
||||
};
|
||||
};
|
||||
rpc RefundTransaction(RefundTransactionRequest) returns (RefundTransactionResponse){
|
||||
option (google.api.http) = {
|
||||
post: "/RefundTransaction"
|
||||
body: "*"
|
||||
};
|
||||
};
|
||||
}
|
||||
message CreateNewTransactionsRequest
|
||||
{
|
||||
@@ -146,3 +158,36 @@ message GetAllTransactionsByFilterResponseModel
|
||||
messages.TransactionType type = 7;
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyTransaction Messages
|
||||
message VerifyTransactionRequest
|
||||
{
|
||||
int64 transaction_id = 1;
|
||||
string ref_id = 2;
|
||||
messages.PaymentStatus status = 3;
|
||||
google.protobuf.Timestamp payment_date = 4;
|
||||
}
|
||||
|
||||
message VerifyTransactionResponse
|
||||
{
|
||||
int64 transaction_id = 1;
|
||||
messages.PaymentStatus status = 2;
|
||||
string ref_id = 3;
|
||||
string message = 4;
|
||||
}
|
||||
|
||||
// RefundTransaction Messages
|
||||
message RefundTransactionRequest
|
||||
{
|
||||
int64 transaction_id = 1;
|
||||
string refund_reason = 2;
|
||||
google.protobuf.Int64Value refund_amount = 3;
|
||||
}
|
||||
|
||||
message RefundTransactionResponse
|
||||
{
|
||||
int64 original_transaction_id = 1;
|
||||
int64 refund_transaction_id = 2;
|
||||
int64 refund_amount = 3;
|
||||
string message = 4;
|
||||
}
|
||||
|
||||
@@ -67,13 +67,14 @@ message CreateNewUserRequest
|
||||
google.protobuf.StringValue first_name = 1;
|
||||
google.protobuf.StringValue last_name = 2;
|
||||
string mobile = 3;
|
||||
google.protobuf.StringValue national_code = 4;
|
||||
google.protobuf.StringValue avatar_path = 5;
|
||||
google.protobuf.Int64Value parent_id = 6;
|
||||
bool email_notifications = 7;
|
||||
bool sms_notifications = 8;
|
||||
bool push_notifications = 9;
|
||||
google.protobuf.Timestamp birth_date = 10;
|
||||
google.protobuf.StringValue email = 4;
|
||||
google.protobuf.StringValue national_code = 5;
|
||||
google.protobuf.StringValue avatar_path = 6;
|
||||
google.protobuf.Int64Value parent_id = 7;
|
||||
bool email_notifications = 8;
|
||||
bool sms_notifications = 9;
|
||||
bool push_notifications = 10;
|
||||
google.protobuf.Timestamp birth_date = 11;
|
||||
}
|
||||
message CreateNewUserResponse
|
||||
{
|
||||
@@ -84,14 +85,15 @@ message UpdateUserRequest
|
||||
int64 id = 1;
|
||||
google.protobuf.StringValue first_name = 2;
|
||||
google.protobuf.StringValue last_name = 3;
|
||||
google.protobuf.StringValue national_code = 4;
|
||||
google.protobuf.StringValue avatar_path = 5;
|
||||
bool is_rules_accepted = 6;
|
||||
google.protobuf.Timestamp rules_accepted_at = 7;
|
||||
bool email_notifications = 8;
|
||||
bool sms_notifications = 9;
|
||||
bool push_notifications = 10;
|
||||
google.protobuf.Timestamp birth_date = 11;
|
||||
google.protobuf.StringValue email = 4;
|
||||
google.protobuf.StringValue national_code = 5;
|
||||
google.protobuf.StringValue avatar_path = 6;
|
||||
bool is_rules_accepted = 7;
|
||||
google.protobuf.Timestamp rules_accepted_at = 8;
|
||||
bool email_notifications = 9;
|
||||
bool sms_notifications = 10;
|
||||
bool push_notifications = 11;
|
||||
google.protobuf.Timestamp birth_date = 12;
|
||||
}
|
||||
message DeleteUserRequest
|
||||
{
|
||||
@@ -107,16 +109,17 @@ message GetUserResponse
|
||||
google.protobuf.StringValue first_name = 2;
|
||||
google.protobuf.StringValue last_name = 3;
|
||||
string mobile = 4;
|
||||
google.protobuf.StringValue national_code = 5;
|
||||
google.protobuf.StringValue avatar_path = 6;
|
||||
google.protobuf.Int64Value parent_id = 7;
|
||||
string referral_code = 8;
|
||||
bool is_mobile_verified = 9;
|
||||
google.protobuf.Timestamp mobile_verified_at = 10;
|
||||
bool email_notifications = 11;
|
||||
bool sms_notifications = 12;
|
||||
bool push_notifications = 13;
|
||||
google.protobuf.Timestamp birth_date = 14;
|
||||
google.protobuf.StringValue email = 5;
|
||||
google.protobuf.StringValue national_code = 6;
|
||||
google.protobuf.StringValue avatar_path = 7;
|
||||
google.protobuf.Int64Value parent_id = 8;
|
||||
string referral_code = 9;
|
||||
bool is_mobile_verified = 10;
|
||||
google.protobuf.Timestamp mobile_verified_at = 11;
|
||||
bool email_notifications = 12;
|
||||
bool sms_notifications = 13;
|
||||
bool push_notifications = 14;
|
||||
google.protobuf.Timestamp birth_date = 15;
|
||||
}
|
||||
message GetAllUserByFilterRequest
|
||||
{
|
||||
|
||||
@@ -43,6 +43,12 @@ service UserCartsContract
|
||||
|
||||
};
|
||||
};
|
||||
rpc ClearCart(ClearCartRequest) returns (ClearCartResponse){
|
||||
option (google.api.http) = {
|
||||
post: "/ClearCart"
|
||||
body: "*"
|
||||
};
|
||||
};
|
||||
}
|
||||
message CreateNewUserCartsRequest
|
||||
{
|
||||
@@ -105,3 +111,16 @@ message GetAllUserCartsByFilterResponseModel
|
||||
string product_thumbnail_path = 9;
|
||||
google.protobuf.Timestamp created = 10;
|
||||
}
|
||||
|
||||
// ClearCart Messages
|
||||
message ClearCartRequest
|
||||
{
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message ClearCartResponse
|
||||
{
|
||||
int64 user_id = 1;
|
||||
int32 removed_items_count = 2;
|
||||
string message = 3;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ service UserOrderContract
|
||||
body: "*"
|
||||
};
|
||||
};
|
||||
rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse){
|
||||
option (google.api.http) = {
|
||||
post: "/CancelOrder"
|
||||
body: "*"
|
||||
};
|
||||
};
|
||||
}
|
||||
message CreateNewUserOrderRequest
|
||||
{
|
||||
@@ -225,3 +231,19 @@ message SubmitShopBuyOrderResponse
|
||||
{
|
||||
int64 id = 1;
|
||||
}
|
||||
|
||||
// CancelOrder Messages
|
||||
message CancelOrderRequest
|
||||
{
|
||||
int64 order_id = 1;
|
||||
string cancel_reason = 2;
|
||||
bool refund_payment = 3;
|
||||
}
|
||||
|
||||
message CancelOrderResponse
|
||||
{
|
||||
int64 order_id = 1;
|
||||
messages.DeliveryStatus status = 2;
|
||||
string message = 3;
|
||||
bool refund_processed = 4;
|
||||
}
|
||||
|
||||
@@ -187,6 +187,10 @@ using (var scope = app.Services.CreateScope())
|
||||
});
|
||||
|
||||
app.Logger.LogInformation("✅ Hangfire recurring job 'weekly-commission-calculation' registered (Cron: 5 0 * * 0 - Sunday 00:05 UTC)");
|
||||
|
||||
// Daya Loan Check: Every 15 minutes
|
||||
CMSMicroservice.WebApi.Workers.DayaLoanCheckWorker.Schedule(recurringJobManager);
|
||||
app.Logger.LogInformation("✅ Hangfire recurring job 'daya-loan-check' registered (Cron: */15 * * * * - Every 15 minutes)");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -5,6 +5,9 @@ using CMSMicroservice.Application.TransactionsCQ.Commands.UpdateTransactions;
|
||||
using CMSMicroservice.Application.TransactionsCQ.Commands.DeleteTransactions;
|
||||
using CMSMicroservice.Application.TransactionsCQ.Queries.GetTransactions;
|
||||
using CMSMicroservice.Application.TransactionsCQ.Queries.GetAllTransactionsByFilter;
|
||||
using CMSMicroservice.Application.TransactionsCQ.Commands.VerifyTransaction;
|
||||
using CMSMicroservice.Application.TransactionsCQ.Commands.RefundTransaction;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
public class TransactionsService : TransactionsContract.TransactionsContractBase
|
||||
{
|
||||
@@ -34,4 +37,14 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<GetAllTransactionsByFilterRequest, GetAllTransactionsByFilterQuery, GetAllTransactionsByFilterResponse>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<VerifyTransactionResponse> VerifyTransaction(VerifyTransactionRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<VerifyTransactionRequest, VerifyTransactionCommand, VerifyTransactionResponse>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<RefundTransactionResponse> RefundTransaction(RefundTransactionRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<RefundTransactionRequest, RefundTransactionCommand, RefundTransactionResponse>(request, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ using CMSMicroservice.Application.UserCartsCQ.Commands.UpdateUserCarts;
|
||||
using CMSMicroservice.Application.UserCartsCQ.Commands.DeleteUserCarts;
|
||||
using CMSMicroservice.Application.UserCartsCQ.Queries.GetUserCarts;
|
||||
using CMSMicroservice.Application.UserCartsCQ.Queries.GetAllUserCartsByFilter;
|
||||
using CMSMicroservice.Application.UserCartsCQ.Commands.ClearCart;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
public class UserCartsService : UserCartsContract.UserCartsContractBase
|
||||
{
|
||||
@@ -34,4 +36,9 @@ public class UserCartsService : UserCartsContract.UserCartsContractBase
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<GetAllUserCartsByFilterRequest, GetAllUserCartsByFilterQuery, GetAllUserCartsByFilterResponse>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<ClearCartResponse> ClearCart(ClearCartRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<ClearCartRequest, ClearCartCommand, ClearCartResponse>(request, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ using CMSMicroservice.Application.UserOrderCQ.Commands.DeleteUserOrder;
|
||||
using CMSMicroservice.Application.UserOrderCQ.Queries.GetUserOrder;
|
||||
using CMSMicroservice.Application.UserOrderCQ.Queries.GetAllUserOrderByFilter;
|
||||
using CMSMicroservice.Application.UserOrderCQ.Commands.SubmitShopBuyOrder;
|
||||
using CMSMicroservice.Application.UserOrderCQ.Commands.CancelOrder;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
public class UserOrderService : UserOrderContract.UserOrderContractBase
|
||||
{
|
||||
@@ -39,4 +41,9 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<SubmitShopBuyOrderRequest, SubmitShopBuyOrderCommand, SubmitShopBuyOrderResponse>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<CancelOrderResponse> CancelOrder(CancelOrderRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<CancelOrderRequest, CancelOrderCommand, CancelOrderResponse>(request, context);
|
||||
}
|
||||
}
|
||||
|
||||
121
src/CMSMicroservice.WebApi/Workers/DayaLoanCheckWorker.cs
Normal file
121
src/CMSMicroservice.WebApi/Workers/DayaLoanCheckWorker.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Hangfire;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Application.DayaLoanCQ.Commands.CheckDayaLoanStatus;
|
||||
using CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using CMSMicroservice.Infrastructure.Persistence;
|
||||
using System.Linq;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Worker برای استعلام خودکار وضعیت وام دایا (هر 15 دقیقه)
|
||||
/// </summary>
|
||||
public class DayaLoanCheckWorker
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ILogger<DayaLoanCheckWorker> _logger;
|
||||
|
||||
public DayaLoanCheckWorker(
|
||||
IMediator mediator,
|
||||
ApplicationDbContext context,
|
||||
ILogger<DayaLoanCheckWorker> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// متد اصلی که توسط Hangfire فراخوانی میشود
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 3)]
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
_logger.LogInformation("DayaLoanCheckWorker started at {Time}", DateTime.UtcNow);
|
||||
|
||||
try
|
||||
{
|
||||
// پیدا کردن کاربرانی که اعتبار دایا را دریافت نکردهاند
|
||||
var pendingUsers = await _context.Users
|
||||
.Where(u =>
|
||||
u.HasReceivedDayaCredit == false &&
|
||||
u.NationalCode != null &&
|
||||
u.NationalCode != "")
|
||||
.Select(u => new { u.Id, u.NationalCode })
|
||||
.ToListAsync();
|
||||
|
||||
if (!pendingUsers.Any())
|
||||
{
|
||||
_logger.LogInformation("No pending users found for Daya loan check");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} users with pending Daya loan status", pendingUsers.Count);
|
||||
|
||||
// استعلام از دایا
|
||||
var checkCommand = new CheckDayaLoanStatusCommand
|
||||
{
|
||||
NationalCodes = pendingUsers.Select(u => u.NationalCode).ToList()
|
||||
};
|
||||
|
||||
var checkResult = await _mediator.Send(checkCommand);
|
||||
|
||||
// پردازش نتایج
|
||||
foreach (var result in checkResult.Results)
|
||||
{
|
||||
// فقط وضعیت PendingReceive را پردازش میکنیم (یعنی وام درخواست شده)
|
||||
if (result.Status == DayaLoanStatus.PendingReceive && !string.IsNullOrEmpty(result.ContractNumber))
|
||||
{
|
||||
var user = pendingUsers.FirstOrDefault(u => u.NationalCode == result.NationalCode);
|
||||
if (user != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// پردازش تایید وام و شارژ کیف پول
|
||||
var processCommand = new ProcessDayaLoanApprovalCommand
|
||||
{
|
||||
UserId = user.Id,
|
||||
ContractNumber = result.ContractNumber
|
||||
};
|
||||
|
||||
var processResult = await _mediator.Send(processCommand);
|
||||
|
||||
_logger.LogInformation("Daya loan processed for user {UserId}. Contract: {ContractNumber}",
|
||||
user.Id, result.ContractNumber);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing Daya loan for user {UserId}", user.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("DayaLoanCheckWorker completed. Checked: {Total}, Processed: {Success}",
|
||||
checkResult.TotalChecked, checkResult.SuccessCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in DayaLoanCheckWorker");
|
||||
throw; // Hangfire will retry
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// متد برای Schedule کردن Worker (هر 15 دقیقه)
|
||||
/// </summary>
|
||||
public static void Schedule(IRecurringJobManager recurringJobManager)
|
||||
{
|
||||
// هر 15 دقیقه: */15 * * * *
|
||||
recurringJobManager.AddOrUpdate<DayaLoanCheckWorker>(
|
||||
"daya-loan-check",
|
||||
worker => worker.ExecuteAsync(),
|
||||
"*/15 * * * *", // هر 15 دقیقه
|
||||
TimeZoneInfo.Utc
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/CMSMicroservice.WebApi/appsettings.Production.json
Normal file
34
src/CMSMicroservice.WebApi/appsettings.Production.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=YOUR_PRODUCTION_SERVER;Database=FourSat_CMS;User Id=YOUR_USER;Password=YOUR_PASSWORD;TrustServerCertificate=True;MultipleActiveResultSets=true"
|
||||
},
|
||||
"Email": {
|
||||
"Enabled": true,
|
||||
"SmtpHost": "smtp.gmail.com",
|
||||
"SmtpPort": 587,
|
||||
"SmtpUsername": "your-production-email@gmail.com",
|
||||
"SmtpPassword": "your-gmail-app-password",
|
||||
"FromEmail": "noreply@foursat.com",
|
||||
"FromName": "FourSat CMS",
|
||||
"EnableSsl": true
|
||||
},
|
||||
"Sms": {
|
||||
"Enabled": true,
|
||||
"Provider": "Kavenegar",
|
||||
"KavenegarApiKey": "YOUR_PRODUCTION_KAVENEGAR_API_KEY",
|
||||
"Sender": "10008663"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "https://api.foursat.com",
|
||||
"Audience": "https://foursat.com",
|
||||
"SecretKey": "YOUR_PRODUCTION_SECRET_KEY_MINIMUM_32_CHARACTERS_LONG"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"UseRealPaymentGateway": false,
|
||||
"PaymentProvider": "BankMellat",
|
||||
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
|
||||
"JwtIssuer": "https://localhost",
|
||||
"JwtAudience": "https://localhost",
|
||||
@@ -39,10 +41,20 @@
|
||||
"KavenegarApiKey": "YOUR_KAVENEGAR_API_KEY",
|
||||
"Sender": "10008663"
|
||||
},
|
||||
"DayaPayment": {
|
||||
"BaseUrl": "https://api.daya.ir",
|
||||
"ApiKey": "YOUR_DAYA_API_KEY"
|
||||
},
|
||||
"BankMellat": {
|
||||
"ServiceUrl": "https://bpm.shaparak.ir/pgwchannel/services/pgw",
|
||||
"TerminalId": "YOUR_TERMINAL_ID",
|
||||
"Username": "YOUR_USERNAME",
|
||||
"Password": "YOUR_PASSWORD"
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"EndpointDefaults": {
|
||||
"Protocols": "Http2"
|
||||
"Protocols": "Http1AndHttp2"
|
||||
}
|
||||
},
|
||||
"Authentication": {
|
||||
|
||||
Reference in New Issue
Block a user