4ef4bfbeef
- payment-gateway.md: سکشن ۸-۱۳ (ZarinPal callback, تخفیف ۱۰۰٪, VAT, ExpirePendingOrders, DeliveryStatus mapping, Production deploy) - CICD-PIPELINE-GUIDE.md: باگ cross-deploy, قالب workflow Production, جدول مقایسه دو محیط - INFRASTRUCTURE-GUIDE.md: سرور Production (45.149.79.127), DB KBS, Proto v0.0.179 - DISCOUNT-STORE-STATUS.md: وضعیت Production Deploy, فلوی پرداخت جدید - SERVER-MIRRORS-CONFIG.md: registries.yaml سرور Production - INDEX.md: تاریخ, توضیحات بروز, لینکهای سریع جدید
1061 lines
31 KiB
Markdown
1061 lines
31 KiB
Markdown
# Payment Gateway Integration Guide
|
||
|
||
## 📋 Overview
|
||
|
||
## 🔄 جریان پرداخت در سیستم
|
||
|
||
### 1️⃣ دریافت پول از کاربر (Payment IN)
|
||
```
|
||
کاربر → Gateway/PYMS → بانک → پرداخت موفق
|
||
↓
|
||
Callback به CMS
|
||
↓
|
||
CMS: VerifyTransaction + فعالسازی عضویت
|
||
```
|
||
**توضیح**:
|
||
- درگاه اینترنتی در **Gateway/PYMS** است (نه CMS)
|
||
- CMS فقط **نتیجه پرداخت را دریافت** میکند (از طریق Callback)
|
||
- سپس عملیات بعدی (فعالسازی، اضافه PV، Wallet) را انجام میدهد
|
||
- **Transaction System** در CMS برای این کار طراحی شده
|
||
|
||
### 2️⃣ پرداخت به کاربر (Payout)
|
||
```
|
||
ادمین تایید برداشت → CMS → DayaPaymentService → واریز به حساب کاربر
|
||
```
|
||
**توضیح**:
|
||
- این سند فقط برای **Payout** است
|
||
- سیستم از دو پیادهسازی پشتیبانی میکند:
|
||
|
||
1. **MockPaymentGatewayService** - برای Development و Testing
|
||
2. **DayaPaymentService** - API واقعی Daya (برای واریز به حساب کاربران)
|
||
|
||
---
|
||
|
||
## 🏗️ Architecture
|
||
|
||
### Interface Design
|
||
|
||
```csharp
|
||
public interface IPaymentGatewayService
|
||
{
|
||
// پرداخت (خرید بسته)
|
||
Task<PaymentInitiateResult> InitiatePaymentAsync(
|
||
PaymentRequest request,
|
||
CancellationToken cancellationToken = default);
|
||
|
||
// تایید پرداخت (Callback)
|
||
Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||
string refId,
|
||
string verificationToken,
|
||
CancellationToken cancellationToken = default);
|
||
|
||
// برداشت/پرداخت به کاربر (Withdrawal)
|
||
Task<PayoutResult> ProcessPayoutAsync(
|
||
PayoutRequest request,
|
||
CancellationToken cancellationToken = default);
|
||
}
|
||
```
|
||
|
||
### DTO Models
|
||
|
||
#### PaymentRequest
|
||
```csharp
|
||
public class PaymentRequest
|
||
{
|
||
public long UserId { get; set; }
|
||
public string Mobile { get; set; }
|
||
public decimal Amount { get; set; }
|
||
public string Description { get; set; }
|
||
public string CallbackUrl { get; set; }
|
||
}
|
||
```
|
||
|
||
#### PaymentInitiateResult
|
||
```csharp
|
||
public class PaymentInitiateResult
|
||
{
|
||
public bool IsSuccess { get; set; }
|
||
public string? RefId { get; set; }
|
||
public string? GatewayUrl { get; set; }
|
||
public string? ErrorMessage { get; set; }
|
||
}
|
||
```
|
||
|
||
#### PaymentVerificationResult
|
||
```csharp
|
||
public class PaymentVerificationResult
|
||
{
|
||
public bool IsSuccess { get; set; }
|
||
public string RefId { get; set; }
|
||
public string? TrackingCode { get; set; }
|
||
public decimal Amount { get; set; }
|
||
public string? Message { get; set; }
|
||
}
|
||
```
|
||
|
||
#### PayoutRequest
|
||
```csharp
|
||
public class PayoutRequest
|
||
{
|
||
public long UserId { get; set; }
|
||
public string Iban { get; set; }
|
||
public decimal Amount { get; set; }
|
||
public string? Description { get; set; }
|
||
}
|
||
```
|
||
|
||
#### PayoutResult
|
||
```csharp
|
||
public class PayoutResult
|
||
{
|
||
public bool IsSuccess { get; set; }
|
||
public string? TransactionId { get; set; }
|
||
public string Message { get; set; }
|
||
public DateTime ProcessedAt { get; set; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔧 Implementation Details
|
||
|
||
### 1. MockPaymentGatewayService
|
||
|
||
**Purpose**: Development و Testing بدون نیاز به API واقعی
|
||
|
||
**Features**:
|
||
- ✅ IBAN validation (IR prefix, 26 characters)
|
||
- ✅ Amount validation (min 10,000 Toman)
|
||
- ✅ Mock RefId generation (MockRef_{timestamp})
|
||
- ✅ Simulated network delay (500ms)
|
||
- ✅ Comprehensive logging
|
||
- ✅ Gateway URL generation (mock://payment)
|
||
|
||
**Usage**:
|
||
```json
|
||
{
|
||
"UseRealPaymentGateway": false
|
||
}
|
||
```
|
||
|
||
**Example**:
|
||
```csharp
|
||
var result = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
|
||
{
|
||
UserId = 123,
|
||
Mobile = "09123456789",
|
||
Amount = 100000,
|
||
Description = "خرید بسته طلایی",
|
||
CallbackUrl = "https://yoursite.com/payment/callback"
|
||
});
|
||
|
||
// result.IsSuccess = true
|
||
// result.RefId = "MockRef_1701619200"
|
||
// result.GatewayUrl = "mock://payment/MockRef_1701619200"
|
||
```
|
||
|
||
---
|
||
|
||
### 2. DayaPaymentService
|
||
|
||
**Purpose**: یکپارچهسازی با API واقعی Daya برای پرداخت و برداشت
|
||
|
||
**Configuration**:
|
||
```json
|
||
{
|
||
"UseRealPaymentGateway": true,
|
||
"PaymentProvider": "Daya",
|
||
"DayaPayment": {
|
||
"BaseUrl": "https://api.daya.ir",
|
||
"ApiKey": "YOUR_DAYA_API_KEY"
|
||
}
|
||
}
|
||
```
|
||
|
||
**API Endpoints**:
|
||
|
||
#### Initiate Payment
|
||
```http
|
||
POST {BaseUrl}/api/v1/payment/initiate
|
||
Content-Type: application/json
|
||
X-API-Key: {ApiKey}
|
||
|
||
{
|
||
"userId": 123,
|
||
"mobile": "09123456789",
|
||
"amount": 100000,
|
||
"description": "خرید بسته طلایی",
|
||
"callbackUrl": "https://yoursite.com/payment/callback"
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"refId": "DAYA123456789",
|
||
"gatewayUrl": "https://gateway.daya.ir/pay/DAYA123456789",
|
||
"errorMessage": null
|
||
}
|
||
```
|
||
|
||
#### Verify Payment
|
||
```http
|
||
POST {BaseUrl}/api/v1/payment/verify
|
||
Content-Type: application/json
|
||
X-API-Key: {ApiKey}
|
||
|
||
{
|
||
"refId": "DAYA123456789",
|
||
"token": "DAYA123456789"
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"refId": "DAYA123456789",
|
||
"trackingCode": "TRACK987654321",
|
||
"amount": 100000,
|
||
"message": "تراکنش موفق"
|
||
}
|
||
```
|
||
|
||
#### Process Payout
|
||
```http
|
||
POST {BaseUrl}/api/v1/payout/process
|
||
Content-Type: application/json
|
||
X-API-Key: {ApiKey}
|
||
|
||
{
|
||
"userId": 123,
|
||
"iban": "IR123456789012345678901234",
|
||
"amount": 50000,
|
||
"description": "برداشت کمیسیون"
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"transactionId": "TXN_123456789",
|
||
"message": "پرداخت با موفقیت انجام شد",
|
||
"processedAt": "2024-12-02T10:30:00Z"
|
||
}
|
||
```
|
||
|
||
**Error Handling**:
|
||
```csharp
|
||
try
|
||
{
|
||
var response = await _httpClient.PostAsJsonAsync(url, request, cancellationToken);
|
||
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
_logger.LogError("Daya API error: StatusCode={StatusCode}", response.StatusCode);
|
||
return new PaymentInitiateResult
|
||
{
|
||
IsSuccess = false,
|
||
ErrorMessage = $"خطا در ارتباط با سرویس پرداخت: {response.StatusCode}"
|
||
};
|
||
}
|
||
|
||
var result = await response.Content.ReadFromJsonAsync<DayaInitiateResponse>(cancellationToken);
|
||
// Process result...
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error in InitiatePaymentAsync");
|
||
return new PaymentInitiateResult
|
||
{
|
||
IsSuccess = false,
|
||
ErrorMessage = "خطای غیرمنتظره در برقراری ارتباط با سرویس پرداخت"
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3. BankMellatPaymentService
|
||
|
||
**Purpose**: یکپارچهسازی با IPG بانک ملت (SOAP Web Service)
|
||
|
||
**Configuration**:
|
||
```json
|
||
{
|
||
"UseRealPaymentGateway": true,
|
||
"PaymentProvider": "BankMellat",
|
||
"BankMellat": {
|
||
"ServiceUrl": "https://bpm.shaparak.ir/pgwchannel/services/pgw",
|
||
"TerminalId": "YOUR_TERMINAL_ID",
|
||
"Username": "YOUR_USERNAME",
|
||
"Password": "YOUR_PASSWORD"
|
||
}
|
||
}
|
||
```
|
||
|
||
**SOAP Operations**:
|
||
|
||
#### bpPayRequest (Initiate Payment)
|
||
```xml
|
||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
|
||
xmlns:ns="http://interfaces.core.sw.bps.com/">
|
||
<soap:Body>
|
||
<ns:bpPayRequest>
|
||
<terminalId>{TERMINAL_ID}</terminalId>
|
||
<userName>{USERNAME}</userName>
|
||
<userPassword>{PASSWORD}</userPassword>
|
||
<orderId>{ORDER_ID}</orderId>
|
||
<amount>{AMOUNT_IN_RIALS}</amount>
|
||
<localDate>{yyyyMMdd}</localDate>
|
||
<localTime>{HHmmss}</localTime>
|
||
<additionalData>{DESCRIPTION}</additionalData>
|
||
<callBackUrl>{CALLBACK_URL}</callBackUrl>
|
||
<payerId>0</payerId>
|
||
</ns:bpPayRequest>
|
||
</soap:Body>
|
||
</soap:Envelope>
|
||
```
|
||
|
||
**Response**:
|
||
```xml
|
||
<soap:Envelope>
|
||
<soap:Body>
|
||
<ns:bpPayRequestResponse>
|
||
<return>{REF_ID}</return> <!-- Success: positive number, Error: negative number -->
|
||
</ns:bpPayRequestResponse>
|
||
</soap:Body>
|
||
</soap:Envelope>
|
||
```
|
||
|
||
#### bpVerifyRequest (Verify Payment)
|
||
```xml
|
||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
|
||
xmlns:ns="http://interfaces.core.sw.bps.com/">
|
||
<soap:Body>
|
||
<ns:bpVerifyRequest>
|
||
<terminalId>{TERMINAL_ID}</terminalId>
|
||
<userName>{USERNAME}</userName>
|
||
<userPassword>{PASSWORD}</userPassword>
|
||
<orderId>{ORDER_ID}</orderId>
|
||
<saleOrderId>{ORDER_ID}</saleOrderId>
|
||
<saleReferenceId>{REF_ID}</saleReferenceId>
|
||
</ns:bpVerifyRequest>
|
||
</soap:Body>
|
||
</soap:Envelope>
|
||
```
|
||
|
||
#### bpSettleRequest (Settle Payment)
|
||
```xml
|
||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
|
||
xmlns:ns="http://interfaces.core.sw.bps.com/">
|
||
<soap:Body>
|
||
<ns:bpSettleRequest>
|
||
<terminalId>{TERMINAL_ID}</terminalId>
|
||
<userName>{USERNAME}</userName>
|
||
<userPassword>{PASSWORD}</userPassword>
|
||
<orderId>{ORDER_ID}</orderId>
|
||
<saleOrderId>{ORDER_ID}</saleOrderId>
|
||
<saleReferenceId>{REF_ID}</saleReferenceId>
|
||
</ns:bpSettleRequest>
|
||
</soap:Body>
|
||
</soap:Envelope>
|
||
```
|
||
|
||
**Error Codes**:
|
||
|
||
| Code | Description (Persian) |
|
||
|------|----------------------|
|
||
| 0 | تراکنش موفق |
|
||
| 11 | شماره کارت نامعتبر است |
|
||
| 12 | موجودی کافی نیست |
|
||
| 13 | رمز نادرست است |
|
||
| 14 | تعداد دفعات وارد کردن رمز بیش از حد مجاز است |
|
||
| 15 | کارت نامعتبر است |
|
||
| 17 | کاربر از انجام تراکنش منصرف شده است |
|
||
| 18 | تاریخ انقضای کارت گذشته است |
|
||
| 21 | پذیرنده نامعتبر است |
|
||
| 23 | خطای امنیتی رخ داده است |
|
||
| 24 | اطلاعات کاربری پذیرنده نامعتبر است |
|
||
| 25 | مبلغ نامعتبر است |
|
||
| 41 | شماره درخواست تکراری است |
|
||
| 43 | قبلا درخواست Verify داده شده است |
|
||
| 51 | تراکنش تکراری است |
|
||
|
||
**Limitations**:
|
||
- ⚠️ Direct payout (ProcessPayoutAsync) **not supported** by Bank Mellat IPG
|
||
- ℹ️ For withdrawals, use **Shaparak Paya** or third-party services like Fanapay, IPG.ir
|
||
|
||
---
|
||
|
||
## ⚙️ Service Registration (ConfigureServices.cs)
|
||
|
||
```csharp
|
||
// Payment Gateway Service - برای Development از Mock استفاده میشود
|
||
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}");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Mock برای Development و Testing
|
||
services.AddScoped<IPaymentGatewayService, MockPaymentGatewayService>();
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📝 Usage Examples
|
||
|
||
### Purchase Package (InitiatePaymentAsync)
|
||
|
||
```csharp
|
||
// In Command Handler
|
||
public class PurchaseGoldenPackageCommandHandler : IRequestHandler<PurchaseGoldenPackageCommand, long>
|
||
{
|
||
private readonly IPaymentGatewayService _paymentGateway;
|
||
|
||
public async Task<long> Handle(PurchaseGoldenPackageCommand request, CancellationToken ct)
|
||
{
|
||
// Initiate payment
|
||
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
|
||
{
|
||
UserId = request.UserId,
|
||
Mobile = user.Mobile,
|
||
Amount = packagePrice,
|
||
Description = "خرید بسته طلایی",
|
||
CallbackUrl = "https://yoursite.com/payment/callback"
|
||
}, ct);
|
||
|
||
if (!paymentResult.IsSuccess)
|
||
{
|
||
throw new InvalidOperationException(paymentResult.ErrorMessage);
|
||
}
|
||
|
||
// Create transaction record
|
||
var transaction = new Transaction
|
||
{
|
||
UserId = request.UserId,
|
||
Type = TransactionType.PackagePurchase,
|
||
Amount = packagePrice,
|
||
Status = TransactionStatus.Pending,
|
||
RefId = paymentResult.RefId,
|
||
Description = "خرید بسته طلایی"
|
||
};
|
||
|
||
await _context.Transactions.AddAsync(transaction, ct);
|
||
await _context.SaveChangesAsync(ct);
|
||
|
||
// Redirect user to gateway
|
||
return transaction.Id; // Return transaction ID for frontend to track
|
||
}
|
||
}
|
||
```
|
||
|
||
### Verify Payment (Callback)
|
||
|
||
```csharp
|
||
public class VerifyGoldenPackagePurchaseCommandHandler : IRequestHandler<VerifyGoldenPackagePurchaseCommand>
|
||
{
|
||
private readonly IPaymentGatewayService _paymentGateway;
|
||
|
||
public async Task Handle(VerifyGoldenPackagePurchaseCommand request, CancellationToken ct)
|
||
{
|
||
// Verify payment
|
||
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
|
||
request.Authority,
|
||
request.Authority,
|
||
ct);
|
||
|
||
if (!verifyResult.IsSuccess)
|
||
{
|
||
transaction.Status = TransactionStatus.Failed;
|
||
transaction.ErrorMessage = verifyResult.Message;
|
||
throw new InvalidOperationException(verifyResult.Message);
|
||
}
|
||
|
||
// Update transaction
|
||
transaction.Status = TransactionStatus.Completed;
|
||
transaction.CompletedAt = DateTime.UtcNow;
|
||
|
||
// Activate club membership
|
||
var clubMembership = new ClubMembership
|
||
{
|
||
UserId = transaction.UserId,
|
||
Status = ClubMembershipStatus.Active,
|
||
StartDate = DateTime.UtcNow,
|
||
EndDate = DateTime.UtcNow.AddMonths(1),
|
||
PurchaseMethod = PackagePurchaseMethod.DirectPurchase
|
||
};
|
||
|
||
await _context.ClubMemberships.AddAsync(clubMembership, ct);
|
||
await _context.SaveChangesAsync(ct);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Process Withdrawal (ProcessPayoutAsync)
|
||
|
||
```csharp
|
||
public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawalCommand>
|
||
{
|
||
private readonly IPaymentGatewayService _paymentGateway;
|
||
|
||
public async Task Handle(ProcessWithdrawalCommand request, CancellationToken ct)
|
||
{
|
||
if (request.IsApproved)
|
||
{
|
||
if (payout.WithdrawalMethod == WithdrawalMethod.Diamond)
|
||
{
|
||
// Credit user wallet
|
||
userWallet.DiscountBalance += payout.TotalAmount;
|
||
}
|
||
else if (payout.WithdrawalMethod == WithdrawalMethod.Cash)
|
||
{
|
||
// Process bank transfer
|
||
var payoutResult = await _paymentGateway.ProcessPayoutAsync(new PayoutRequest
|
||
{
|
||
UserId = payout.UserId,
|
||
Iban = payout.Iban,
|
||
Amount = payout.TotalAmount,
|
||
Description = $"برداشت کمیسیون هفته {payout.WeekNumber}"
|
||
}, ct);
|
||
|
||
if (payoutResult.IsSuccess)
|
||
{
|
||
payout.Status = CommissionStatus.Withdrawn;
|
||
payout.CompletedAt = DateTime.UtcNow;
|
||
payout.TransactionId = payoutResult.TransactionId;
|
||
}
|
||
else
|
||
{
|
||
payout.Status = CommissionStatus.PaymentFailed;
|
||
payout.ErrorMessage = payoutResult.Message;
|
||
}
|
||
}
|
||
|
||
// Record history
|
||
await _context.CommissionPayoutHistories.AddAsync(new CommissionPayoutHistory
|
||
{
|
||
PayoutId = payout.Id,
|
||
TransactionType = payout.Status == CommissionStatus.Withdrawn
|
||
? TransactionType.Withdrawn
|
||
: TransactionType.PaymentFailed,
|
||
Amount = payout.TotalAmount,
|
||
ProcessedBy = _currentUserService.UserId,
|
||
ProcessedAt = DateTime.UtcNow
|
||
}, ct);
|
||
|
||
await _context.SaveChangesAsync(ct);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🧪 Testing Guide
|
||
|
||
### Unit Testing with Mock
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task InitiatePayment_Should_Return_Success_With_Valid_Data()
|
||
{
|
||
// Arrange
|
||
var mockLogger = new Mock<ILogger<MockPaymentGatewayService>>();
|
||
var service = new MockPaymentGatewayService(mockLogger.Object);
|
||
|
||
var request = new PaymentRequest
|
||
{
|
||
UserId = 123,
|
||
Mobile = "09123456789",
|
||
Amount = 100000,
|
||
Description = "Test payment",
|
||
CallbackUrl = "https://test.com/callback"
|
||
};
|
||
|
||
// Act
|
||
var result = await service.InitiatePaymentAsync(request);
|
||
|
||
// Assert
|
||
Assert.True(result.IsSuccess);
|
||
Assert.NotNull(result.RefId);
|
||
Assert.StartsWith("MockRef_", result.RefId);
|
||
Assert.NotNull(result.GatewayUrl);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ProcessPayout_Should_Fail_With_Invalid_IBAN()
|
||
{
|
||
// Arrange
|
||
var mockLogger = new Mock<ILogger<MockPaymentGatewayService>>();
|
||
var service = new MockPaymentGatewayService(mockLogger.Object);
|
||
|
||
var request = new PayoutRequest
|
||
{
|
||
UserId = 123,
|
||
Iban = "INVALID_IBAN",
|
||
Amount = 50000,
|
||
Description = "Test payout"
|
||
};
|
||
|
||
// Act
|
||
var result = await service.ProcessPayoutAsync(request);
|
||
|
||
// Assert
|
||
Assert.False(result.IsSuccess);
|
||
Assert.Contains("فرمت شماره شبا نامعتبر", result.Message);
|
||
}
|
||
```
|
||
|
||
### Integration Testing
|
||
|
||
```csharp
|
||
public class PaymentGatewayIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||
{
|
||
private readonly HttpClient _client;
|
||
|
||
public PaymentGatewayIntegrationTests(WebApplicationFactory<Program> factory)
|
||
{
|
||
_client = factory.CreateClient();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task PurchaseGoldenPackage_Should_Initiate_Payment()
|
||
{
|
||
// Arrange
|
||
var command = new PurchaseGoldenPackageCommand
|
||
{
|
||
UserId = 123,
|
||
PaymentMethod = PackagePurchaseMethod.DirectPurchase
|
||
};
|
||
|
||
// Act
|
||
var response = await _client.PostAsJsonAsync("/api/package/purchase", command);
|
||
|
||
// Assert
|
||
response.EnsureSuccessStatusCode();
|
||
var transactionId = await response.Content.ReadFromJsonAsync<long>();
|
||
Assert.True(transactionId > 0);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔒 Security Best Practices
|
||
|
||
1. **Configuration Security**:
|
||
- ✅ Store API keys in `appsettings.json` (excluded from git)
|
||
- ✅ Use Azure Key Vault or AWS Secrets Manager in production
|
||
- ✅ Never hardcode credentials in code
|
||
|
||
2. **HTTPS Only**:
|
||
- ✅ Enforce HTTPS for all payment callbacks
|
||
- ✅ Validate SSL certificates
|
||
|
||
3. **Amount Validation**:
|
||
- ✅ Validate min/max amounts before API call
|
||
- ✅ Verify amounts match on callback
|
||
|
||
4. **IBAN Validation**:
|
||
- ✅ Format: IR + 24 digits = 26 characters
|
||
- ✅ Validate before payout processing
|
||
|
||
5. **Idempotency**:
|
||
- ✅ Use unique OrderId for each payment
|
||
- ✅ Store RefId to prevent duplicate processing
|
||
|
||
6. **Error Handling**:
|
||
- ✅ Never expose internal errors to users
|
||
- ✅ Log detailed errors for debugging
|
||
- ✅ Return user-friendly error messages
|
||
|
||
---
|
||
|
||
## 📊 Monitoring & Logging
|
||
|
||
### Recommended Logs
|
||
|
||
```csharp
|
||
// Success
|
||
_logger.LogInformation(
|
||
"Payment initiated successfully: UserId={UserId}, Amount={Amount}, RefId={RefId}",
|
||
request.UserId, request.Amount, result.RefId);
|
||
|
||
// Failure
|
||
_logger.LogError(
|
||
"Payment initiation failed: UserId={UserId}, Amount={Amount}, Error={Error}",
|
||
request.UserId, request.Amount, result.ErrorMessage);
|
||
|
||
// API Error
|
||
_logger.LogError(
|
||
"Payment gateway API error: StatusCode={StatusCode}, Response={Response}",
|
||
response.StatusCode, responseContent);
|
||
```
|
||
|
||
### Sentry Integration
|
||
|
||
```csharp
|
||
try
|
||
{
|
||
var result = await _paymentGateway.InitiatePaymentAsync(request, ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SentrySdk.CaptureException(ex, scope =>
|
||
{
|
||
scope.SetTag("payment_provider", "Daya");
|
||
scope.SetExtra("user_id", request.UserId);
|
||
scope.SetExtra("amount", request.Amount);
|
||
});
|
||
throw;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🚀 Production Deployment Checklist
|
||
|
||
- [ ] Obtain Daya API credentials (BaseUrl + ApiKey)
|
||
- [ ] Obtain Bank Mellat credentials (TerminalId, Username, Password)
|
||
- [ ] Test in sandbox environment
|
||
- [ ] Update `appsettings.Production.json` with credentials
|
||
- [ ] Set `UseRealPaymentGateway = true`
|
||
- [ ] Configure HTTPS callback URLs
|
||
- [ ] Set up monitoring (Sentry/Application Insights)
|
||
- [ ] Configure retry policies (Polly)
|
||
- [ ] Test full payment flow (Initiate → Callback → Verify)
|
||
- [ ] Test withdrawal flow (Request → Approve → Payout)
|
||
- [ ] Document production URLs and credentials (secure location)
|
||
|
||
---
|
||
|
||
## 📞 Support & Troubleshooting
|
||
|
||
### Common Issues
|
||
|
||
**Issue**: "Payment gateway API error: 401 Unauthorized"
|
||
- **Solution**: Check API key in `appsettings.json`, verify credentials
|
||
|
||
**Issue**: "IBAN validation failed"
|
||
- **Solution**: Ensure IBAN starts with "IR" and is exactly 26 characters
|
||
|
||
**Issue**: "Bank Mellat returns negative RefId"
|
||
- **Solution**: Check error code mapping, verify TerminalId/Username/Password
|
||
|
||
**Issue**: "HttpClient timeout"
|
||
- **Solution**: Increase timeout in `ConfigureServices.cs`, check network connectivity
|
||
|
||
---
|
||
|
||
## 📚 References
|
||
|
||
- [Daya API Documentation](https://api.daya.ir/docs) (placeholder)
|
||
- [Bank Mellat IPG Guide](https://bpm.shaparak.ir/) (official)
|
||
- [Shaparak Paya Documentation](https://www.shaparak.ir/)
|
||
- [ISO 8601 Week Numbering](https://en.wikipedia.org/wiki/ISO_8601)
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 🆕 فاز ۲ — ZarinPal + PaymentTransaction (بهمن ۱۴۰۴)
|
||
|
||
### ۴. ZarinPalPaymentService (فعال)
|
||
|
||
**Purpose**: درگاه پرداخت مستقیم زرینپال — بدون PYMS واسط
|
||
|
||
**Configuration**:
|
||
```json
|
||
{
|
||
"UseRealPaymentGateway": true,
|
||
"PaymentProvider": "zarinpal",
|
||
"ZarinPal": {
|
||
"MerchantId": "6b098fc8-f490-47a1-aac3-1de1a1b84404",
|
||
"UseSandbox": true
|
||
}
|
||
}
|
||
```
|
||
|
||
**API Endpoints**:
|
||
|
||
#### InitiatePayment (درخواست پرداخت)
|
||
```
|
||
POST https://sandbox.zarinpal.com/pg/v4/payment/request.json
|
||
{
|
||
"merchant_id": "...",
|
||
"amount": 100000,
|
||
"description": "خرید پکیج طلایی",
|
||
"callback_url": "https://cms.se.kbs1.ir/api/payment/callback",
|
||
"metadata": { "mobile": "09123456789" }
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"data": {
|
||
"authority": "A00000000000000000000000000123456",
|
||
"code": 100
|
||
}
|
||
}
|
||
```
|
||
|
||
#### VerifyPayment (تأیید پرداخت)
|
||
```
|
||
POST https://sandbox.zarinpal.com/pg/v4/payment/verify.json
|
||
{
|
||
"merchant_id": "...",
|
||
"authority": "A00000000000000000000000000123456",
|
||
"amount": 100000
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"data": {
|
||
"code": 100,
|
||
"ref_id": 123456789,
|
||
"card_pan": "6037****1234",
|
||
"card_hash": "...",
|
||
"fee_type": "Merchant",
|
||
"fee": 0
|
||
}
|
||
}
|
||
```
|
||
|
||
**Sandbox URL**: `https://sandbox.zarinpal.com/pg/StartPay/{Authority}`
|
||
**Production URL**: `https://zarinpal.com/pg/StartPay/{Authority}`
|
||
|
||
**PaymentVerificationResult (بروزشده)**:
|
||
```csharp
|
||
public class PaymentVerificationResult
|
||
{
|
||
public bool IsSuccess { get; set; }
|
||
public string RefId { get; set; }
|
||
public string? TrackingCode { get; set; }
|
||
public decimal Amount { get; set; }
|
||
public string? Message { get; set; }
|
||
public string? CardPan { get; set; } // 🆕 شماره کارت ماسکشده
|
||
public string? CardHash { get; set; } // 🆕 هش کارت
|
||
public int? VerificationCode { get; set; } // 🆕 کد تأیید زرینپال
|
||
}
|
||
```
|
||
|
||
**Service Registration (بروزشده)**:
|
||
```csharp
|
||
var paymentProvider = configuration.GetValue<string>("PaymentProvider", "zarinpal");
|
||
|
||
if (paymentProvider?.ToLower() == "zarinpal")
|
||
{
|
||
services.AddHttpClient<IPaymentGatewayService, ZarinPalPaymentService>();
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### ۵. جدول PaymentTransaction (جداگانه از Transaction)
|
||
|
||
**Purpose**: ذخیره جزئیات سطح درگاه — جدا از Transaction entity اصلی
|
||
|
||
**Entity**: `Domain/Entities/Payment/PaymentTransaction.cs`
|
||
|
||
```csharp
|
||
public class PaymentTransaction : BaseEntity
|
||
{
|
||
public string GatewayProvider { get; set; } // "zarinpal"
|
||
public string MerchantId { get; set; }
|
||
public long Amount { get; set; }
|
||
public string? CallbackUrl { get; set; }
|
||
public string? Description { get; set; }
|
||
public string? Mobile { get; set; }
|
||
public long? UserId { get; set; }
|
||
|
||
// Request
|
||
public int? RequestStatusCode { get; set; } // 100 = success
|
||
public string? RequestStatusMessage { get; set; }
|
||
public string? Authority { get; set; } // ZarinPal authority
|
||
|
||
// Verification
|
||
public bool PaymentStatus { get; set; }
|
||
public int? VerificationStatusCode { get; set; }
|
||
public string? VerificationStatusMessage { get; set; }
|
||
public string? CardHash { get; set; }
|
||
public string? CardPan { get; set; } // ماسکشده: 6037****1234
|
||
public long? RefId { get; set; }
|
||
|
||
// Relations
|
||
public long? TransactionId { get; set; }
|
||
public long? OrderId { get; set; }
|
||
}
|
||
```
|
||
|
||
**Indexes**: Authority, GatewayProvider, UserId, TransactionId, RefId
|
||
**Migration**: `AddPaymentTransactionTable`
|
||
|
||
**جریان کامل پرداخت**:
|
||
```
|
||
1. PlaceOrderCommandHandler → InitiatePayment → PaymentTransaction ایجاد (PaymentStatus=false)
|
||
2. کاربر → ریدایرکت به ZarinPal
|
||
3. ZarinPal → Callback به /api/payment/callback
|
||
4. PaymentCallbackController → VerifyPayment → PaymentTransaction بروز (PaymentStatus=true, CardPan, RefId)
|
||
5. CompleteOrderPaymentCommandHandler → Transaction + Order + Wallet بروز
|
||
```
|
||
|
||
**مصرفکنندهها**:
|
||
| سرویس | عملیات |
|
||
|--------|--------|
|
||
| `PlaceOrderCommandHandler` | ایجاد PaymentTransaction بعد از InitiatePayment |
|
||
| `PaymentCallbackController` | بروزرسانی بعد از VerifyPayment |
|
||
| `TransactionsService` | ایجاد/بروزرسانی در CustomerPaymentRequest/Verification |
|
||
| `PackageService` | ایجاد/بروزرسانی در CustomerPurchasePackage/Verify |
|
||
|
||
---
|
||
|
||
### ۶. فیکس نمایش وضعیت پرداخت سفارشات تخفیفی
|
||
|
||
**مشکل**: `DiscountOrderService.GetOrderById/GetUserOrders` از `Mapster.Adapt<>()` استفاده میکرد. نامها متفاوت بودند:
|
||
- Domain: `PaymentStatus` (enum: Success=0, Reject=1, Pending=2)
|
||
- Proto: `payment_completed` (bool)
|
||
|
||
Mapster نمیتونست enum رو به bool مپ کنه → همیشه `false` (در انتظار پرداخت).
|
||
|
||
**رفع**: جایگزینی Mapster با مپینگ دستی:
|
||
```csharp
|
||
PaymentCompleted = result.PaymentStatus == DomainEnums.PaymentStatus.Success
|
||
```
|
||
|
||
### ۷. فیکس DeliveryStatus بعد از پرداخت
|
||
|
||
**مشکل**: فروشگاه تخفیفی بعد از پرداخت موفق، `DeliveryStatus = InTransit` (ارسال شده) ست میکرد. ولی فروشگاه عادی `Pending` نگه میداشت.
|
||
|
||
**رفع**: هر دو handler (`CompleteOrderPaymentCommandHandler` و `PlaceOrderCommandHandler`) به `DeliveryStatus.Pending` تغییر کردند — ادمین باید وضعیت پستی رو مشخص کنه.
|
||
|
||
---
|
||
|
||
### ۸. فیکس ZarinPal Callback URL (اسفند ۱۴۰۴)
|
||
|
||
**مشکل**: `PurchasePackageCommandHandler` از `yourdomain.com` به صورت hardcode استفاده میکرد.
|
||
|
||
**رفع**: خواندن از `IConfiguration`:
|
||
```csharp
|
||
var cmsBaseUrl = _configuration["CmsBaseUrl"];
|
||
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"];
|
||
```
|
||
|
||
**appsettings.json (Production)**:
|
||
```json
|
||
{
|
||
"CmsBaseUrl": "https://cms.kbs1.ir",
|
||
"FrontOfficeBaseUrl": "https://foursat.kbs1.ir"
|
||
}
|
||
```
|
||
**appsettings.json (Staging)**:
|
||
```json
|
||
{
|
||
"CmsBaseUrl": "https://cms.se.kbs1.ir",
|
||
"FrontOfficeBaseUrl": "https://foursat.se.kbs1.ir"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### ۹. اجبار تخفیف ۱۰۰٪ (اسفند ۱۴۰۴)
|
||
|
||
**تغییر بیزینسی**: کاربر دیگه نمیتونه درصد تخفیف رو انتخاب کنه — **همیشه حداکثر تخفیف** (MaxDiscountPercent) اعمال میشه.
|
||
|
||
**تغییرات CMS (بکند):**
|
||
- `PlaceOrderCommandHandler`: همیشه `MaxDiscountPercent` محصول استفاده میشه
|
||
- فیلد `requested_discount_percent` از request نادیده گرفته میشه
|
||
|
||
**تغییرات FrontOffice:**
|
||
- حذف `MudSlider` و `MudNumericField` از `Checkout.razor`
|
||
- حذف کامل بخش نمایش موجودی تخفیفی
|
||
- Badge محصولات: نمایش درصد واقعی (مثلاً "۳۰٪ تخفیف") بجای "۱۰۰٪ تخفیفی"
|
||
|
||
---
|
||
|
||
### ۱۰. نمایش مالیات (VAT) در Checkout (اسفند ۱۴۰۴)
|
||
|
||
جدول خلاصه مالی کامل اضافه شد:
|
||
|
||
| فیلد | توضیح |
|
||
|------|-------|
|
||
| جمع کل | قبل از تخفیف |
|
||
| تخفیف | مجموع DiscountAmount |
|
||
| مبلغ پس از تخفیف | بعد از کسر تخفیف |
|
||
| مالیات ۹٪ | `VatCalculator` روی مبلغ درگاه |
|
||
| **مبلغ قابل پرداخت** | مبلغ درگاه + مالیات |
|
||
|
||
---
|
||
|
||
### ۱۱. سرویس Expire سفارشات معلق (اسفند ۱۴۰۴)
|
||
|
||
**فایل**: `ExpirePendingOrdersService.cs` — `BackgroundService`
|
||
|
||
| تنظیم | مقدار |
|
||
|-------|-------|
|
||
| بررسی | هر ۵ دقیقه |
|
||
| انقضا | بعد از ۳۰ دقیقه `PaymentStatus=Pending` |
|
||
| عملیات | `PaymentStatus=Reject`, `DeliveryStatus=Cancelled`, آزادسازی رزرو انبار |
|
||
| ساعت | `DateTime.Now` (نه UtcNow — DB از ساعت محلی استفاده میکنه) |
|
||
|
||
---
|
||
|
||
### ۱۲. فیکس DeliveryStatus مپینگ (اسفند ۱۴۰۴)
|
||
|
||
**مشکل**: Domain `DeliveryStatus.Pending(1)` مستقیم cast به Proto `PROCESSING(1)` میشد → سفارشات failed نشون میدادن "در حال پردازش".
|
||
|
||
**راهحل**: `MapDeliveryStatus()` و `MapPaymentStatus()` اضافه شدن:
|
||
|
||
| Domain | Proto |
|
||
|--------|-------|
|
||
| `PaymentStatus.Success(0)` | `COMPLETED(1)` |
|
||
| `PaymentStatus.Reject(1)` | `FAILED(2)` |
|
||
| `PaymentStatus.Pending(2)` | `PENDING(0)` |
|
||
| `DeliveryStatus.None(0)` | `PENDING(0)` |
|
||
| `DeliveryStatus.Pending(1)` | `PROCESSING(1)` |
|
||
| `DeliveryStatus.InTransit(2)` | `SHIPPED(2)` |
|
||
| `DeliveryStatus.Delivered(3)` | `DELIVERED(3)` |
|
||
| `DeliveryStatus.Returned/Cancelled(4,5)` | `CANCELLED(4)` |
|
||
|
||
+ وقتی پرداخت ناموفقه: `order.DeliveryStatus = DeliveryStatus.Cancelled`
|
||
|
||
---
|
||
|
||
### ۱۳. استقرار Production (اسفند ۱۴۰۴)
|
||
|
||
**Merge از `kub-stage` به `production`** — هر ۳ ریپو:
|
||
- CMS: ۳ conflict حل شد (workflow, Dockerfile, appsettings)
|
||
- FrontOffice: ۱ conflict (workflow)
|
||
- BackOffice: ۲ conflict (workflow, Dockerfile)
|
||
|
||
**Migration دیتابیس Production**: ۷ migration اعمال شد:
|
||
1. `AddDiscountProductImages`
|
||
2. `AddInventorySystem`
|
||
3. `u19` + `u20`
|
||
4. `AddBlogAndContentEntities`
|
||
5. `RemoveImagePathMaxLength`
|
||
6. `AddPaymentTransactionTable`
|
||
|
||
**ZarinPal در Production**: `UseSandbox: true` → "درگاه فعال نمیباشد" (عمدی)
|
||
|
||
---
|
||
|
||
**Last Updated**: February 17, 2026
|
||
**Version**: 3.0
|
||
**Proto Version**: 0.0.179
|
||
**Status**: ✅ ZarinPal Active (Sandbox) + PaymentTransaction + ExpireOrders + Production Deployed
|