4d6d77531d
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m3s
- SP sp_CalculateWeeklyBalances: added @PackageId, @InputMaxBalancesPerLeg, @InputMaxNetworkLevel params; filters by PackageId - SP sp_CalculateWeeklyCommissionPool: added @PackageId param; all queries filter by PackageId/WeeklyPoolId for isolation - ICommissionCalculationStrategy: added optional PackageId param to both methods - StoredProcedureCommissionCalculationStrategy: loops per-package for both Balance and Pool methods; filters by packageId if provided - OrmCommissionCalculationStrategy: signature updated to match interface - TriggerWeeklyCalculationCommand: added PackageId optional field - TriggerWeeklyCalculationCommandHandler: passes PackageId to strategy - GetWeeklyCommissionPoolQuery: added optional PackageId filter - GetWeeklyCommissionPoolQueryHandler: filters pool by PackageId if provided - GetAllWeeklyPoolsQuery/Handler/DTO: added PackageId filter + PackageTitle in response - Proto commission.proto: added package_id to TriggerWeeklyCalculationRequest, GetWeeklyCommissionPoolRequest, WeeklyCommissionPoolModel, GetWeeklyCommissionPoolResponse, GetAllWeeklyPoolsRequest - CommissionProfile: added explicit mappings for TriggerWeeklyCalculation, GetWeeklyCommissionPool, GetAllWeeklyPools Fixes: SP picks wrong pool when multiple packages per week Fixes: SP ignores PackageId on ForceRecalculate (now uses WeeklyPoolId) Fixes: Zero-balance pools not fully recorded (now sets TotalBalances=0, ValuePerBalance=0)
1372 lines
54 KiB
Markdown
1372 lines
54 KiB
Markdown
# راهنمای فنی پیادهسازی درگاه پرداخت زرینپال (ZarinPal)
|
||
|
||
> **نسخه:** 1.0
|
||
> **تاریخ:** اسفند ۱۴۰۴
|
||
> **تکنولوژی:** ASP.NET Core (.NET 9) — Clean Architecture — Strategy Pattern
|
||
> **API زرینپال:** v4
|
||
|
||
---
|
||
|
||
## فهرست مطالب
|
||
|
||
1. [معماری کلی](#1-معماری-کلی)
|
||
2. [ساختار فایلها و لایهها](#2-ساختار-فایلها-و-لایهها)
|
||
3. [فلوی پرداخت (Payment Flow)](#3-فلوی-پرداخت)
|
||
4. [پیادهسازی گامبهگام](#4-پیادهسازی-گامبهگام)
|
||
- [4.1 تنظیمات (Configuration)](#41-تنظیمات-configuration)
|
||
- [4.2 Interface — لایه Application](#42-interface--لایه-application)
|
||
- [4.3 DTOs — مدلهای Request/Response](#43-dtos--مدلهای-requestresponse)
|
||
- [4.4 Domain Entity — PaymentTransaction](#44-domain-entity--paymenttransaction)
|
||
- [4.5 سرویس زرینپال — لایه Infrastructure](#45-سرویس-زرینپال--لایه-infrastructure)
|
||
- [4.6 ثبت سرویس (DI Registration)](#46-ثبت-سرویس-di-registration)
|
||
- [4.7 Mock Service (برای تست)](#47-mock-service-برای-تست)
|
||
5. [فلوی Initiate Payment (ایجاد پرداخت)](#5-فلوی-initiate-payment)
|
||
6. [فلوی Verify Payment (تأیید پرداخت)](#6-فلوی-verify-payment)
|
||
7. [نکات مهم تومان/ریال](#7-نکات-مهم-تومانریال)
|
||
8. [مدیریت خطای API زرینپال](#8-مدیریت-خطای-api-زرینپال)
|
||
9. [Sandbox vs Production](#9-sandbox-vs-production)
|
||
10. [الگوی استفاده در Handler/Controller](#10-الگوی-استفاده-در-handlercontroller)
|
||
11. [Migration — جدول PaymentTransaction](#11-migration--جدول-paymenttransaction)
|
||
12. [چکلیست پیادهسازی در پروژه جدید](#12-چکلیست-پیادهسازی-در-پروژه-جدید)
|
||
13. [کدهای خطای زرینپال](#13-کدهای-خطای-زرینپال)
|
||
14. [Sequence Diagram](#14-sequence-diagram)
|
||
|
||
---
|
||
|
||
## 1. معماری کلی
|
||
|
||
از الگوی **Strategy Pattern** استفاده شده. یک `IPaymentGatewayService` اینترفیس وجود داره که چندین پیادهسازی (ZarinPal, Mock, و ...) داره. سوئیچ بین providerها فقط با تغییر یک مقدار در `appsettings.json` انجام میشه:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ Application Layer │
|
||
│ │
|
||
│ IPaymentGatewayService (Interface) │
|
||
│ ├── InitiatePaymentAsync(PaymentRequest) │
|
||
│ ├── VerifyPaymentAsync(refId, status, amount) │
|
||
│ └── ProcessPayoutAsync(PayoutRequest) │
|
||
└────────────────────┬────────────────────────────────┘
|
||
│
|
||
┌───────────┼───────────┐
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
┌──────────────┐┌──────────┐┌──────────────┐
|
||
│ ZarinPal ││ Mock ││ (Other) │
|
||
│ PaymentSvc ││ Svc ││ Provider │
|
||
└──────────────┘└──────────┘└──────────────┘
|
||
Infrastructure Layer
|
||
```
|
||
|
||
**مزایا:**
|
||
- Caller (Handler/Controller) هیچ وابستگی مستقیمی به زرینپال نداره
|
||
- سوئیچ بین درگاهها بدون تغییر کد — فقط config
|
||
- تست آسان با Mock
|
||
- هر Provider خودش DTO و Error Handling مخصوص خودش رو داره
|
||
|
||
---
|
||
|
||
## 2. ساختار فایلها و لایهها
|
||
|
||
```
|
||
YourProject/
|
||
├── src/
|
||
│ ├── Application/ ← لایه Application (Interface + DTOs)
|
||
│ │ └── Common/
|
||
│ │ └── Interfaces/
|
||
│ │ └── IPaymentGatewayService.cs ← اینترفیس + تمام DTOs
|
||
│ │
|
||
│ ├── Domain/ ← لایه Domain (Entity)
|
||
│ │ └── Entities/
|
||
│ │ └── Payment/
|
||
│ │ └── PaymentTransaction.cs ← Entity ذخیره تراکنش درگاه
|
||
│ │
|
||
│ ├── Infrastructure/ ← لایه Infrastructure (پیادهسازی)
|
||
│ │ ├── ConfigureServices.cs ← DI Registration (Strategy Switch)
|
||
│ │ └── Services/
|
||
│ │ └── Payment/
|
||
│ │ ├── ZarinPalPaymentService.cs ← پیادهسازی زرینپال
|
||
│ │ └── MockPaymentGatewayService.cs ← Mock برای تست
|
||
│ │
|
||
│ └── WebApi/ ← لایه Presentation
|
||
│ └── appsettings.json ← تنظیمات MerchantId و UseSandbox
|
||
```
|
||
|
||
---
|
||
|
||
## 3. فلوی پرداخت
|
||
|
||
```
|
||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||
│ Client │ │ Your │ │ ZarinPal │ │ ZarinPal │
|
||
│ (Browser)│ │ Server │ │ API │ │ Gateway │
|
||
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
|
||
│ │ │ │
|
||
│ 1. درخواست │ │ │
|
||
│ پرداخت │ │ │
|
||
│──────────────>│ │ │
|
||
│ │ │ │
|
||
│ │ 2. POST │ │
|
||
│ │ /request.json│ │
|
||
│ │──────────────>│ │
|
||
│ │ │ │
|
||
│ │ 3. Authority │ │
|
||
│ │<──────────────│ │
|
||
│ │ │ │
|
||
│ 4. Redirect │ │ │
|
||
│ to Gateway │ │ │
|
||
│<──────────────│ │ │
|
||
│ │ │ │
|
||
│ 5. پرداخت │ │ │
|
||
│──────────────────────────────────────────────>│
|
||
│ │ │ │
|
||
│ 6. Callback │ │ │
|
||
│ (Authority │ │ │
|
||
│ + Status) │ │ │
|
||
│──────────────>│ │ │
|
||
│ │ │ │
|
||
│ │ 7. POST │ │
|
||
│ │ /verify.json │ │
|
||
│ │──────────────>│ │
|
||
│ │ │ │
|
||
│ │ 8. نتیجه │ │
|
||
│ │ (RefId, │ │
|
||
│ │ CardPan) │ │
|
||
│ │<──────────────│ │
|
||
│ │ │ │
|
||
│ 9. نتیجه │ │ │
|
||
│ نهایی │ │ │
|
||
│<──────────────│ │ │
|
||
```
|
||
|
||
---
|
||
|
||
## 4. پیادهسازی گامبهگام
|
||
|
||
### 4.1 تنظیمات (Configuration)
|
||
|
||
فایل `appsettings.json`:
|
||
|
||
```json
|
||
{
|
||
"PaymentProvider": "zarinpal",
|
||
"ZarinPal": {
|
||
"MerchantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||
"UseSandbox": true
|
||
}
|
||
}
|
||
```
|
||
|
||
| کلید | توضیح |
|
||
|------|-------|
|
||
| `PaymentProvider` | انتخاب درگاه فعال: `"zarinpal"`, `"mock"` |
|
||
| `ZarinPal:MerchantId` | مرچنت آیدی از پنل زرینپال (GUID) |
|
||
| `ZarinPal:UseSandbox` | `true` = محیط تست / `false` = محیط واقعی |
|
||
|
||
**Production** (`appsettings.Production.json` یا K8s Secret):
|
||
```json
|
||
{
|
||
"PaymentProvider": "zarinpal",
|
||
"ZarinPal": {
|
||
"MerchantId": "your-real-merchant-id",
|
||
"UseSandbox": false
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.2 Interface — لایه Application
|
||
|
||
```csharp
|
||
namespace YourProject.Application.Common.Interfaces;
|
||
|
||
/// <summary>
|
||
/// Interface یکپارچه برای درگاههای پرداخت — Strategy Pattern
|
||
/// </summary>
|
||
public interface IPaymentGatewayService
|
||
{
|
||
/// <summary>
|
||
/// مرحله ۱: ارسال درخواست پرداخت و دریافت لینک درگاه
|
||
/// </summary>
|
||
Task<PaymentInitiateResult> InitiatePaymentAsync(
|
||
PaymentRequest request,
|
||
CancellationToken cancellationToken = default);
|
||
|
||
/// <summary>
|
||
/// مرحله ۲: تأیید پرداخت بعد از بازگشت کاربر از درگاه (بدون مبلغ)
|
||
/// </summary>
|
||
Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||
string refId,
|
||
string verificationToken,
|
||
CancellationToken cancellationToken = default);
|
||
|
||
/// <summary>
|
||
/// مرحله ۲ (نسخه با مبلغ): تأیید پرداخت — زرینپال مبلغ رو در Verify نیاز داره
|
||
/// ⚠ این overload رو برای زرینپال حتماً استفاده کنید
|
||
/// </summary>
|
||
Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||
string refId,
|
||
string verificationToken,
|
||
decimal amountInToman,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
// Default implementation — throws اگه provider پیادهسازی نکنه
|
||
throw new NotImplementedException(
|
||
"درگاه پرداخت باید متد VerifyPaymentAsync با مبلغ را پیادهسازی کند");
|
||
}
|
||
|
||
/// <summary>
|
||
/// واریز (Payout) — زرینپال ساپورت نمیکنه
|
||
/// </summary>
|
||
Task<PayoutResult> ProcessPayoutAsync(
|
||
PayoutRequest request,
|
||
CancellationToken cancellationToken = default);
|
||
}
|
||
```
|
||
|
||
> ⚠ **نکته مهم:** زرینپال در API Verify مبلغ (Amount) رو هم نیاز داره. اگه مبلغ ارسال نشه (0 باشه) خطای `Code=-1` برمیگردونه. حتماً از overload سهآرگومانه (`refId`, `status`, `amountInToman`) استفاده کنید.
|
||
|
||
---
|
||
|
||
### 4.3 DTOs — مدلهای Request/Response
|
||
|
||
```csharp
|
||
// ── درخواست ایجاد پرداخت ──
|
||
public class PaymentRequest
|
||
{
|
||
/// <summary>مبلغ به تومان (سرویس خودش ×10 میکنه برای ریال)</summary>
|
||
public decimal Amount { get; set; }
|
||
public long UserId { get; set; }
|
||
public string Mobile { get; set; } = string.Empty;
|
||
public string Description { get; set; } = string.Empty;
|
||
/// <summary>آدرسی که زرینپال بعد از پرداخت کاربر رو Redirect میکنه</summary>
|
||
public string CallbackUrl { get; set; } = string.Empty;
|
||
}
|
||
|
||
// ── نتیجه ایجاد پرداخت ──
|
||
public class PaymentInitiateResult
|
||
{
|
||
public bool IsSuccess { get; set; }
|
||
/// <summary>Authority — شناسه یکتای تراکنش در زرینپال</summary>
|
||
public string? RefId { get; set; }
|
||
/// <summary>URL درگاه برای Redirect کاربر</summary>
|
||
public string? GatewayUrl { get; set; }
|
||
public string? ErrorMessage { get; set; }
|
||
}
|
||
|
||
// ── نتیجه تأیید پرداخت ──
|
||
public class PaymentVerificationResult
|
||
{
|
||
public bool IsSuccess { get; set; }
|
||
/// <summary>Authority</summary>
|
||
public string RefId { get; set; } = string.Empty;
|
||
/// <summary>شماره پیگیری بانکی (RefId عددی از زرینپال)</summary>
|
||
public string? TrackingCode { get; set; }
|
||
/// <summary>مبلغ تأیید شده (به تومان)</summary>
|
||
public decimal Amount { get; set; }
|
||
public string? Message { get; set; }
|
||
/// <summary>شماره کارت ماسکشده (مثال: 6037-99**-****-1234)</summary>
|
||
public string? CardPan { get; set; }
|
||
/// <summary>هش کارت پرداختکننده</summary>
|
||
public string? CardHash { get; set; }
|
||
/// <summary>کد وضعیت (100=موفق, 101=قبلاً تأیید شده)</summary>
|
||
public int? VerificationCode { get; set; }
|
||
}
|
||
|
||
// ── درخواست واریز (Payout) — زرینپال ندارد ──
|
||
public class PayoutRequest
|
||
{
|
||
public decimal Amount { get; set; }
|
||
public long UserId { get; set; }
|
||
public string Iban { get; set; } = string.Empty;
|
||
public string AccountHolderName { get; set; } = string.Empty;
|
||
public string Description { get; set; } = string.Empty;
|
||
public string InternalRefId { get; set; } = string.Empty;
|
||
}
|
||
|
||
// ── نتیجه واریز ──
|
||
public class PayoutResult
|
||
{
|
||
public bool IsSuccess { get; set; }
|
||
public string? BankRefId { get; set; }
|
||
public string? TrackingCode { get; set; }
|
||
public string? Message { get; set; }
|
||
public DateTime ProcessedAt { get; set; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.4 Domain Entity — PaymentTransaction
|
||
|
||
این Entity برای لاگ کردن تمام تراکنشهای درگاه پرداخت در دیتابیسه:
|
||
|
||
```csharp
|
||
namespace YourProject.Domain.Entities.Payment;
|
||
|
||
/// <summary>
|
||
/// ذخیره تراکنشهای درگاه پرداخت — یک رکورد برای هر درخواست پرداخت
|
||
/// </summary>
|
||
public class PaymentTransaction : BaseAuditableEntity
|
||
{
|
||
// ── اطلاعات درخواست (مرحله Request) ──
|
||
|
||
/// <summary>نام درگاه (zarinpal, mock, ...)</summary>
|
||
public string GatewayProvider { get; set; } = string.Empty;
|
||
|
||
/// <summary>مرچنت آیدی استفادهشده</summary>
|
||
public string MerchantId { get; set; } = string.Empty;
|
||
|
||
/// <summary>مبلغ تراکنش (به تومان)</summary>
|
||
public long Amount { get; set; }
|
||
|
||
/// <summary>Callback URL ارسالشده به درگاه</summary>
|
||
public string CallbackUrl { get; set; } = string.Empty;
|
||
|
||
/// <summary>توضیح تراکنش</summary>
|
||
public string Description { get; set; } = string.Empty;
|
||
|
||
/// <summary>شماره موبایل کاربر</summary>
|
||
public string? Mobile { get; set; }
|
||
|
||
/// <summary>شناسه کاربر</summary>
|
||
public long? UserId { get; set; }
|
||
|
||
// ── پاسخ Request API ──
|
||
|
||
/// <summary>کد وضعیت از Request API (100=موفق)</summary>
|
||
public int? RequestStatusCode { get; set; }
|
||
public string? RequestStatusMessage { get; set; }
|
||
|
||
/// <summary>Authority — شناسه یکتای تراکنش در زرینپال</summary>
|
||
public string? Authority { get; set; }
|
||
|
||
// ── وضعیت نهایی ──
|
||
|
||
/// <summary>آیا پرداخت موفق بود؟</summary>
|
||
public bool PaymentStatus { get; set; }
|
||
|
||
// ── نتیجه Verify API ──
|
||
|
||
/// <summary>کد وضعیت از Verify API (100=موفق, 101=تکراری)</summary>
|
||
public int? VerificationStatusCode { get; set; }
|
||
public string? VerificationStatusMessage { get; set; }
|
||
|
||
/// <summary>هش کارت بانکی</summary>
|
||
public string? CardHash { get; set; }
|
||
|
||
/// <summary>شماره کارت ماسکشده</summary>
|
||
public string? CardPan { get; set; }
|
||
|
||
/// <summary>شماره پیگیری بانکی</summary>
|
||
public string? RefId { get; set; }
|
||
|
||
// ── ارتباط با سیستم داخلی ──
|
||
|
||
/// <summary>شناسه تراکنش داخلی (اختیاری)</summary>
|
||
public long? TransactionId { get; set; }
|
||
|
||
/// <summary>شناسه سفارش (اختیاری)</summary>
|
||
public string? OrderId { get; set; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.5 سرویس زرینپال — لایه Infrastructure
|
||
|
||
```csharp
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
using YourProject.Application.Common.Interfaces;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace YourProject.Infrastructure.Services.Payment;
|
||
|
||
public class ZarinPalPaymentService : IPaymentGatewayService
|
||
{
|
||
private readonly HttpClient _httpClient;
|
||
private readonly ILogger<ZarinPalPaymentService> _logger;
|
||
private readonly string _merchantId;
|
||
private readonly bool _useSandbox;
|
||
|
||
// ── آدرسهای API ──
|
||
private const string ProductionApiBase = "https://api.zarinpal.com";
|
||
private const string ProductionStartPay = "https://www.zarinpal.com";
|
||
private const string SandboxApiBase = "https://sandbox.zarinpal.com";
|
||
private const string SandboxStartPay = "https://sandbox.zarinpal.com";
|
||
|
||
// ── Endpoints (مشترک بین Sandbox و Production) ──
|
||
private const string RequestEndpoint = "/pg/v4/payment/request.json";
|
||
private const string VerifyEndpoint = "/pg/v4/payment/verify.json";
|
||
private const string StartPayPath = "/pg/StartPay/";
|
||
|
||
// ── JSON Config ──
|
||
// ⚠ زرینپال snake_case میخواد: merchant_id, callback_url, ...
|
||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||
{
|
||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||
};
|
||
|
||
public ZarinPalPaymentService(
|
||
HttpClient httpClient,
|
||
IConfiguration configuration,
|
||
ILogger<ZarinPalPaymentService> logger)
|
||
{
|
||
_httpClient = httpClient;
|
||
_logger = logger;
|
||
|
||
_merchantId = configuration["ZarinPal:MerchantId"]
|
||
?? throw new InvalidOperationException("ZarinPal:MerchantId is not configured.");
|
||
|
||
_useSandbox = configuration.GetValue<bool>("ZarinPal:UseSandbox", true);
|
||
|
||
// Base Address بر اساس محیط
|
||
var apiBase = _useSandbox ? SandboxApiBase : ProductionApiBase;
|
||
_httpClient.BaseAddress = new Uri(apiBase);
|
||
|
||
_logger.LogInformation("ZarinPal initialized. Mode: {Mode}",
|
||
_useSandbox ? "Sandbox" : "Production");
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// مرحله ۱: InitiatePayment — ایجاد تراکنش و دریافت Authority
|
||
// ═══════════════════════════════════════════════════════
|
||
public async Task<PaymentInitiateResult> InitiatePaymentAsync(
|
||
PaymentRequest request,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
try
|
||
{
|
||
// ⚠ مبلغ از caller به تومان میرسد — تبدیل به ریال (×10)
|
||
var amountInToman = (long)request.Amount;
|
||
var amountInRials = amountInToman * 10;
|
||
|
||
var zarinPalRequest = new ZarinPalPaymentRequest
|
||
{
|
||
MerchantId = _merchantId,
|
||
Amount = amountInRials, // باید ریال باشد
|
||
Description = request.Description,
|
||
CallbackUrl = request.CallbackUrl,
|
||
Metadata = new ZarinPalMetadata
|
||
{
|
||
Mobile = string.IsNullOrWhiteSpace(request.Mobile)
|
||
? null : request.Mobile
|
||
}
|
||
};
|
||
|
||
var jsonContent = JsonSerializer.Serialize(zarinPalRequest, JsonOptions);
|
||
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||
|
||
_logger.LogInformation(
|
||
"ZarinPal request: Amount={Toman}T ({Rial}R), User={User}",
|
||
amountInToman, amountInRials, request.UserId);
|
||
|
||
var response = await _httpClient.PostAsync(
|
||
RequestEndpoint, content, cancellationToken);
|
||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||
|
||
// HTTP Error
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
_logger.LogError("ZarinPal HTTP {Code}: {Body}",
|
||
(int)response.StatusCode, responseBody);
|
||
|
||
return new PaymentInitiateResult
|
||
{
|
||
IsSuccess = false,
|
||
ErrorMessage = $"خطای ارتباط با زرینپال (HTTP {(int)response.StatusCode})"
|
||
};
|
||
}
|
||
|
||
var result = JsonSerializer.Deserialize<ZarinPalResponse>(
|
||
responseBody, JsonOptions);
|
||
|
||
// ✅ موفق — Code 100 + Authority
|
||
if (result?.Data?.Code == 100
|
||
&& !string.IsNullOrEmpty(result.Data.Authority))
|
||
{
|
||
var startPayBase = _useSandbox
|
||
? SandboxStartPay : ProductionStartPay;
|
||
var gatewayUrl =
|
||
$"{startPayBase}{StartPayPath}{result.Data.Authority}";
|
||
|
||
_logger.LogInformation(
|
||
"ZarinPal OK: Authority={Auth}, URL={Url}",
|
||
result.Data.Authority, gatewayUrl);
|
||
|
||
return new PaymentInitiateResult
|
||
{
|
||
IsSuccess = true,
|
||
RefId = result.Data.Authority, // ← Authority
|
||
GatewayUrl = gatewayUrl // ← لینک Redirect
|
||
};
|
||
}
|
||
|
||
// ❌ خطای زرینپال
|
||
var errorCode = result?.Errors?.Code
|
||
?? result?.Data?.Code ?? -1;
|
||
var errorMessage = result?.Errors?.Message
|
||
?? "خطای ناشناخته از زرینپال";
|
||
|
||
_logger.LogError("ZarinPal error: Code={Code}, Msg={Msg}",
|
||
errorCode, errorMessage);
|
||
|
||
return new PaymentInitiateResult
|
||
{
|
||
IsSuccess = false,
|
||
ErrorMessage =
|
||
$"خطای درگاه زرینپال (کد {errorCode}): {errorMessage}"
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "ZarinPal InitiatePayment exception");
|
||
return new PaymentInitiateResult
|
||
{
|
||
IsSuccess = false,
|
||
ErrorMessage = $"خطا در ارتباط با درگاه: {ex.Message}"
|
||
};
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// مرحله ۲: VerifyPayment — تأیید پرداخت بعد از Callback
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
/// <summary>Verify بدون مبلغ — سازگاری با interface (ممکنه fail بشه!)</summary>
|
||
public Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||
string refId, string verificationToken,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
_logger.LogWarning("VerifyPayment called without amount — may fail!");
|
||
return VerifyPaymentWithAmountAsync(
|
||
refId, verificationToken, 0, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Verify با مبلغ — نسخه اصلی
|
||
/// refId = Authority, verificationToken = "OK"/"NOK", amount = تومان
|
||
/// </summary>
|
||
public Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||
string refId, string verificationToken, decimal amount,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
return VerifyPaymentWithAmountAsync(
|
||
refId, verificationToken, amount, cancellationToken);
|
||
}
|
||
|
||
private async Task<PaymentVerificationResult> VerifyPaymentWithAmountAsync(
|
||
string refId, string verificationToken,
|
||
decimal amountInToman, CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
// 1. چک Status — "OK" یعنی کاربر پرداخت کرده
|
||
if (!string.Equals(verificationToken, "OK",
|
||
StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_logger.LogWarning(
|
||
"Payment cancelled by user: Authority={Auth}", refId);
|
||
return new PaymentVerificationResult
|
||
{
|
||
IsSuccess = false,
|
||
RefId = refId,
|
||
Message = "پرداخت توسط کاربر لغو شد"
|
||
};
|
||
}
|
||
|
||
// 2. تبدیل تومان → ریال
|
||
var amountInRials = (long)(amountInToman * 10);
|
||
|
||
// 3. ارسال Verify Request
|
||
var verifyRequest = new ZarinPalVerifyRequest
|
||
{
|
||
MerchantId = _merchantId,
|
||
Authority = refId,
|
||
Amount = amountInRials // ⚠ باید ریال باشد
|
||
};
|
||
|
||
var jsonContent = JsonSerializer.Serialize(
|
||
verifyRequest, JsonOptions);
|
||
var content = new StringContent(
|
||
jsonContent, Encoding.UTF8, "application/json");
|
||
|
||
_logger.LogInformation(
|
||
"ZarinPal verify: Auth={Auth}, Amount={T}T ({R}R)",
|
||
refId, (long)amountInToman, amountInRials);
|
||
|
||
var response = await _httpClient.PostAsync(
|
||
VerifyEndpoint, content, cancellationToken);
|
||
var responseBody = await response.Content
|
||
.ReadAsStringAsync(cancellationToken);
|
||
|
||
var result = JsonSerializer.Deserialize<ZarinPalResponse>(
|
||
responseBody, JsonOptions);
|
||
|
||
// 4. بررسی نتیجه
|
||
// Code 100 = موفق | Code 101 = قبلاً verify شده (تکراری)
|
||
if (result?.Data?.Code is 100 or 101)
|
||
{
|
||
_logger.LogInformation(
|
||
"ZarinPal verified: Auth={Auth}, RefId={Ref}, Card={Card}",
|
||
refId, result.Data.RefId, result.Data.CardPan);
|
||
|
||
return new PaymentVerificationResult
|
||
{
|
||
IsSuccess = true,
|
||
RefId = refId,
|
||
TrackingCode = result.Data.RefId?.ToString(),
|
||
Amount = (result.Data.Amount ?? 0) / 10, // ریال → تومان
|
||
CardPan = result.Data.CardPan,
|
||
CardHash = result.Data.CardHash,
|
||
VerificationCode = result.Data.Code,
|
||
Message = result.Data.Code == 101
|
||
? "تراکنش قبلاً تأیید شده"
|
||
: "تراکنش موفق"
|
||
};
|
||
}
|
||
|
||
// ❌ Verify ناموفق
|
||
var errorCode = result?.Errors?.Code
|
||
?? result?.Data?.Code ?? -1;
|
||
var errorMessage = result?.Errors?.Message
|
||
?? "تأیید تراکنش ناموفق";
|
||
|
||
_logger.LogError(
|
||
"ZarinPal verify failed: Auth={Auth}, Code={Code}",
|
||
refId, errorCode);
|
||
|
||
return new PaymentVerificationResult
|
||
{
|
||
IsSuccess = false,
|
||
RefId = refId,
|
||
Message = $"تأیید پرداخت ناموفق (کد {errorCode}): {errorMessage}"
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex,
|
||
"ZarinPal Verify exception: Auth={Auth}", refId);
|
||
return new PaymentVerificationResult
|
||
{
|
||
IsSuccess = false,
|
||
RefId = refId,
|
||
Message = $"خطا در تأیید تراکنش: {ex.Message}"
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>زرینپال Payout ندارد</summary>
|
||
public Task<PayoutResult> ProcessPayoutAsync(
|
||
PayoutRequest request,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
_logger.LogWarning("ZarinPal does not support payout.");
|
||
return Task.FromResult(new PayoutResult
|
||
{
|
||
IsSuccess = false,
|
||
Message = "زرینپال از واریز مستقیم پشتیبانی نمیکند",
|
||
ProcessedAt = DateTime.UtcNow
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// Private DTOs — ساختار API زرینپال v4
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
// ── Request ──
|
||
private class ZarinPalPaymentRequest
|
||
{
|
||
public string MerchantId { get; set; } = string.Empty;
|
||
public long Amount { get; set; } // ریال
|
||
public string Description { get; set; } = string.Empty;
|
||
public string CallbackUrl { get; set; } = string.Empty;
|
||
public ZarinPalMetadata? Metadata { get; set; }
|
||
}
|
||
|
||
private class ZarinPalMetadata
|
||
{
|
||
public string? Mobile { get; set; }
|
||
public string? Email { get; set; }
|
||
}
|
||
|
||
// ── Verify ──
|
||
private class ZarinPalVerifyRequest
|
||
{
|
||
public string MerchantId { get; set; } = string.Empty;
|
||
public long Amount { get; set; } // ریال
|
||
public string Authority { get; set; } = string.Empty;
|
||
}
|
||
|
||
// ── Response ──
|
||
private class ZarinPalResponse
|
||
{
|
||
public ZarinPalResponseData? Data { get; set; }
|
||
|
||
[JsonConverter(typeof(ZarinPalErrorsConverter))]
|
||
public ZarinPalResponseErrors? Errors { get; set; }
|
||
}
|
||
|
||
private class ZarinPalResponseData
|
||
{
|
||
public int? Code { get; set; } // 100=موفق, 101=تکراری
|
||
public string? Message { get; set; }
|
||
public string? Authority { get; set; } // فقط در Request
|
||
public long? RefId { get; set; } // فقط در Verify — شماره پیگیری
|
||
public long? Amount { get; set; } // ریال
|
||
public string? CardPan { get; set; } // شماره کارت ماسکشده
|
||
public string? CardHash { get; set; } // هش کارت
|
||
public string? FeeType { get; set; }
|
||
public long? Fee { get; set; }
|
||
}
|
||
|
||
private class ZarinPalResponseErrors
|
||
{
|
||
public int? Code { get; set; }
|
||
public string? Message { get; set; }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// Custom JSON Converter (مهم!)
|
||
// ═══════════════════════════════════════════════════════
|
||
//
|
||
// زرینپال وقتی خطا نداره، فیلد errors رو بهصورت
|
||
// آرایه خالی [] برمیگردونه — نه object!
|
||
// ولی وقتی خطا هست، {...} object برمیگردونه.
|
||
// بدون این Converter دسیریالایز crash میکنه.
|
||
//
|
||
private class ZarinPalErrorsConverter : JsonConverter<ZarinPalResponseErrors?>
|
||
{
|
||
public override ZarinPalResponseErrors? Read(
|
||
ref Utf8JsonReader reader, Type typeToConvert,
|
||
JsonSerializerOptions options)
|
||
{
|
||
// حالت ۱: [] آرایه خالی — هیچ خطایی نیست
|
||
if (reader.TokenType == JsonTokenType.StartArray)
|
||
{
|
||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { }
|
||
return null;
|
||
}
|
||
|
||
// حالت ۲: {...} object — خطا وجود داره
|
||
if (reader.TokenType == JsonTokenType.StartObject)
|
||
{
|
||
return JsonSerializer
|
||
.Deserialize<ZarinPalResponseErrors>(ref reader);
|
||
}
|
||
|
||
// حالت ۳: null
|
||
if (reader.TokenType == JsonTokenType.Null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
reader.Skip();
|
||
return null;
|
||
}
|
||
|
||
public override void Write(
|
||
Utf8JsonWriter writer, ZarinPalResponseErrors? value,
|
||
JsonSerializerOptions options)
|
||
{
|
||
JsonSerializer.Serialize(writer, value, options);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.6 ثبت سرویس (DI Registration)
|
||
|
||
در `ConfigureServices.cs` لایه Infrastructure:
|
||
|
||
```csharp
|
||
public static IServiceCollection AddInfrastructureServices(
|
||
this IServiceCollection services, IConfiguration configuration)
|
||
{
|
||
// ... سرویسهای دیگر ...
|
||
|
||
// ── Payment Gateway — Strategy Pattern ──
|
||
var paymentProvider = configuration
|
||
.GetValue<string>("PaymentProvider", "Mock")
|
||
?.ToLowerInvariant();
|
||
|
||
switch (paymentProvider)
|
||
{
|
||
case "zarinpal":
|
||
services.AddHttpClient<IPaymentGatewayService, ZarinPalPaymentService>()
|
||
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
|
||
break;
|
||
|
||
case "mock":
|
||
default:
|
||
services.AddScoped<IPaymentGatewayService, MockPaymentGatewayService>();
|
||
break;
|
||
}
|
||
|
||
return services;
|
||
}
|
||
```
|
||
|
||
**نکات:**
|
||
- از `AddHttpClient<>` استفاده شده (نه `AddScoped`) — `HttpClientFactory` مدیریت connection pooling رو بر عهده میگیره
|
||
- `SetHandlerLifetime(5min)` — جلوگیری از DNS stale issues
|
||
- `MockPaymentGatewayService` با `AddScoped` ثبت میشه چون `HttpClient` نیاز نداره
|
||
|
||
---
|
||
|
||
### 4.7 Mock Service (برای تست)
|
||
|
||
```csharp
|
||
public class MockPaymentGatewayService : IPaymentGatewayService
|
||
{
|
||
private readonly ILogger<MockPaymentGatewayService> _logger;
|
||
|
||
public MockPaymentGatewayService(ILogger<MockPaymentGatewayService> logger)
|
||
{
|
||
_logger = logger;
|
||
}
|
||
|
||
public async Task<PaymentInitiateResult> InitiatePaymentAsync(
|
||
PaymentRequest request, CancellationToken ct = default)
|
||
{
|
||
_logger.LogWarning("⚠️ Using MOCK Payment Gateway");
|
||
await Task.Delay(200, ct);
|
||
var refId = $"MOCK-PAY-{DateTime.Now.Ticks}";
|
||
return new PaymentInitiateResult
|
||
{
|
||
IsSuccess = true,
|
||
RefId = refId,
|
||
GatewayUrl = $"https://mock-gateway.local/pay?ref={refId}"
|
||
};
|
||
}
|
||
|
||
public async Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||
string refId, string token, CancellationToken ct = default)
|
||
{
|
||
await Task.Delay(150, ct);
|
||
return new PaymentVerificationResult
|
||
{
|
||
IsSuccess = true,
|
||
RefId = refId,
|
||
TrackingCode = $"TRK-{DateTime.Now.Ticks}",
|
||
Amount = 0,
|
||
Message = "تراکنش موفق (Mock)"
|
||
};
|
||
}
|
||
|
||
public Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||
string refId, string token, decimal amount, CancellationToken ct = default)
|
||
=> VerifyPaymentAsync(refId, token, ct);
|
||
|
||
public async Task<PayoutResult> ProcessPayoutAsync(
|
||
PayoutRequest request, CancellationToken ct = default)
|
||
{
|
||
await Task.Delay(300, ct);
|
||
return new PayoutResult
|
||
{
|
||
IsSuccess = true,
|
||
BankRefId = $"BANK-{DateTime.Now.Ticks}",
|
||
TrackingCode = $"TRK-PAYOUT-{DateTime.Now.Ticks}",
|
||
Message = $"واریز {request.Amount:N0} تومان (Mock)",
|
||
ProcessedAt = DateTime.Now
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. فلوی Initiate Payment
|
||
|
||
الگوی استفاده در Handler/Controller:
|
||
|
||
```csharp
|
||
// 1. ساخت PaymentTransaction برای لاگ
|
||
var paymentTx = new PaymentTransaction
|
||
{
|
||
GatewayProvider = _configuration["PaymentProvider"] ?? "zarinpal",
|
||
MerchantId = _configuration["ZarinPal:MerchantId"] ?? "",
|
||
Amount = amountInToman, // تومان ذخیره میشه
|
||
CallbackUrl = callbackUrl,
|
||
Description = "توضیح تراکنش",
|
||
Mobile = user.PhoneNumber,
|
||
UserId = userId,
|
||
};
|
||
_context.PaymentTransactions.Add(paymentTx);
|
||
await _context.SaveChangesAsync(ct);
|
||
|
||
// 2. ارسال به درگاه
|
||
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
|
||
{
|
||
Amount = amountInToman, // به تومان — سرویس ×10 میکنه
|
||
UserId = userId,
|
||
Mobile = user.PhoneNumber,
|
||
Description = "توضیح تراکنش",
|
||
CallbackUrl = callbackUrl // URL بازگشت کاربر
|
||
}, ct);
|
||
|
||
// 3. آپدیت PaymentTransaction
|
||
if (paymentResult.IsSuccess)
|
||
{
|
||
paymentTx.Authority = paymentResult.RefId; // Authority
|
||
paymentTx.RequestStatusCode = 100;
|
||
paymentTx.RequestStatusMessage = "Success";
|
||
}
|
||
else
|
||
{
|
||
paymentTx.RequestStatusCode = -1;
|
||
paymentTx.RequestStatusMessage = paymentResult.ErrorMessage;
|
||
}
|
||
await _context.SaveChangesAsync(ct);
|
||
|
||
// 4. برگرداندن لینک درگاه به کلاینت
|
||
if (paymentResult.IsSuccess)
|
||
{
|
||
// کلاینت باید کاربر رو Redirect کنه به paymentResult.GatewayUrl
|
||
return new { GatewayUrl = paymentResult.GatewayUrl };
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. فلوی Verify Payment
|
||
|
||
بعد از پرداخت، زرینپال کاربر رو به `CallbackUrl` ریدایرکت میکنه با این Query Parameters:
|
||
|
||
```
|
||
https://your-site.com/payment/callback?Authority=A00000000000000000000000000123456&Status=OK
|
||
```
|
||
|
||
| Parameter | توضیح |
|
||
|-----------|-------|
|
||
| `Authority` | شناسه تراکنش (همون RefId که از Request گرفتید) |
|
||
| `Status` | `"OK"` = پرداخت موفق / `"NOK"` = لغو یا ناموفق |
|
||
|
||
**⚠ مهم:** فقط Status=OK یعنی کاربر پول داده — باید Verify کنید تا مطمئن بشید.
|
||
|
||
```csharp
|
||
// 1. دریافت Authority و Status از Callback
|
||
var authority = request.Authority; // از Query String
|
||
var status = request.Status; // "OK" یا "NOK"
|
||
|
||
// 2. پیدا کردن PaymentTransaction و مبلغ از دیتابیس
|
||
var paymentTx = await _context.PaymentTransactions
|
||
.FirstOrDefaultAsync(pt => pt.Authority == authority, ct);
|
||
|
||
if (paymentTx == null) throw new NotFoundException("تراکنش یافت نشد");
|
||
|
||
var amountInToman = paymentTx.Amount; // مبلغ از دیتابیس
|
||
|
||
// 3. Verify با درگاه
|
||
// ⚠ حتماً مبلغ رو هم بفرستید — زرینپال نیاز داره
|
||
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
|
||
authority, // refId = Authority
|
||
status, // verificationToken = "OK" یا "NOK"
|
||
amountInToman, // مبلغ به تومان — سرویس ×10 میکنه
|
||
ct
|
||
);
|
||
|
||
// 4. آپدیت PaymentTransaction
|
||
paymentTx.PaymentStatus = verifyResult.IsSuccess;
|
||
paymentTx.VerificationStatusCode = verifyResult.VerificationCode;
|
||
paymentTx.VerificationStatusMessage = verifyResult.Message;
|
||
paymentTx.CardPan = verifyResult.CardPan;
|
||
paymentTx.CardHash = verifyResult.CardHash;
|
||
paymentTx.RefId = verifyResult.TrackingCode;
|
||
await _context.SaveChangesAsync(ct);
|
||
|
||
// 5. اگه موفق بود → انجام عملیات بیزینسی (شارژ کیف پول، ثبت سفارش، ...)
|
||
if (verifyResult.IsSuccess)
|
||
{
|
||
// ✅ تراکنش موفق — بیزینس لاجیک اینجا
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. نکات مهم تومان/ریال
|
||
|
||
> **قانون کلی:** همه جا به **تومان** کار کنید. فقط `ZarinPalPaymentService` خودش ×10 (تبدیل به ریال) میکنه.
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────┐
|
||
│ دیتابیس → تومان ذخیره میشه │
|
||
│ PaymentRequest → Amount به تومان ارسال میشه │
|
||
│ ZarinPalService → خودش ×10 میکنه و به ریال به API میفرسته │
|
||
│ Verify Response → Amount ریال برمیگرده → سرویس ÷10 میکنه │
|
||
│ VerifyResult → Amount به تومان برمیگرده │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**باگ رایج:** اگه caller هم ×10 کنه و سرویس هم ×10 کنه = مبلغ ×100 میشه!
|
||
|
||
---
|
||
|
||
## 8. مدیریت خطای API زرینپال
|
||
|
||
زرینپال یه ساختار خاص برای Response داره:
|
||
|
||
**وقتی موفقه:**
|
||
```json
|
||
{
|
||
"data": {
|
||
"code": 100,
|
||
"message": "Success",
|
||
"authority": "A00000000000000000000000000123456",
|
||
"fee_type": "Merchant",
|
||
"fee": 0
|
||
},
|
||
"errors": [] ← آرایه خالی! (نه object)
|
||
}
|
||
```
|
||
|
||
**وقتی خطا داره:**
|
||
```json
|
||
{
|
||
"data": [],
|
||
"errors": { ← object! (نه آرایه)
|
||
"code": -9,
|
||
"message": "The input params invalid, ..."
|
||
}
|
||
}
|
||
```
|
||
|
||
**⚠ مشکل:** فیلد `errors` گاهی `[]` (آرایه) و گاهی `{...}` (object) برمیگردونه. بدون Custom Converter، `System.Text.Json` خطا میده.
|
||
|
||
**راهحل:** `ZarinPalErrorsConverter` — یک `JsonConverter` سفارشی که هر دو حالت رو هندل میکنه. (کد کامل در بخش 4.5)
|
||
|
||
---
|
||
|
||
## 9. Sandbox vs Production
|
||
|
||
| | Sandbox (تست) | Production (واقعی) |
|
||
|---|---|---|
|
||
| **Config** | `"UseSandbox": true` | `"UseSandbox": false` |
|
||
| **API Base** | `https://sandbox.zarinpal.com` | `https://api.zarinpal.com` |
|
||
| **StartPay** | `https://sandbox.zarinpal.com/pg/StartPay/{Authority}` | `https://www.zarinpal.com/pg/StartPay/{Authority}` |
|
||
| **پول واقعی** | ❌ نه | ✅ بله |
|
||
| **MerchantId** | هر GUID — معتبر نیازی نیست | از پنل زرینپال |
|
||
|
||
**Sandbox نکات:**
|
||
- هر MerchantId (حتی fake) کار میکنه
|
||
- پرداخت simulate میشه — پول واقعی کسر نمیشه
|
||
- Authority و RefId واقعی برمیگردونه
|
||
|
||
---
|
||
|
||
## 10. الگوی استفاده در Handler/Controller
|
||
|
||
### الگوی ۱: REST Controller (Callback Endpoint)
|
||
|
||
```csharp
|
||
[ApiController]
|
||
[Route("api/payment")]
|
||
public class PaymentController : ControllerBase
|
||
{
|
||
private readonly IPaymentGatewayService _paymentGateway;
|
||
private readonly ApplicationDbContext _context;
|
||
|
||
// POST: api/payment/initiate
|
||
[HttpPost("initiate")]
|
||
public async Task<IActionResult> Initiate([FromBody] InitiateDto dto)
|
||
{
|
||
// ... ساخت PaymentTransaction + ارسال به درگاه ...
|
||
var result = await _paymentGateway.InitiatePaymentAsync(
|
||
new PaymentRequest { ... });
|
||
|
||
if (result.IsSuccess)
|
||
return Ok(new { result.GatewayUrl });
|
||
|
||
return BadRequest(new { result.ErrorMessage });
|
||
}
|
||
|
||
// GET: api/payment/callback?Authority=xxx&Status=OK
|
||
[HttpGet("callback")]
|
||
public async Task<IActionResult> Callback(
|
||
[FromQuery] string Authority,
|
||
[FromQuery] string Status)
|
||
{
|
||
var paymentTx = await _context.PaymentTransactions
|
||
.FirstOrDefaultAsync(p => p.Authority == Authority);
|
||
|
||
if (paymentTx == null) return NotFound();
|
||
|
||
var result = await _paymentGateway.VerifyPaymentAsync(
|
||
Authority, Status, paymentTx.Amount);
|
||
|
||
// آپدیت PaymentTransaction ...
|
||
|
||
if (result.IsSuccess)
|
||
return Redirect("/payment/success");
|
||
|
||
return Redirect("/payment/failed");
|
||
}
|
||
}
|
||
```
|
||
|
||
### الگوی ۲: CQRS Command Handler
|
||
|
||
```csharp
|
||
public class InitiatePaymentCommandHandler
|
||
: IRequestHandler<InitiatePaymentCommand, PaymentResult>
|
||
{
|
||
private readonly IPaymentGatewayService _paymentGateway;
|
||
private readonly IApplicationDbContext _context;
|
||
private readonly IConfiguration _configuration;
|
||
|
||
public async Task<PaymentResult> Handle(
|
||
InitiatePaymentCommand cmd, CancellationToken ct)
|
||
{
|
||
// 1. ساخت PaymentTransaction
|
||
var tx = new PaymentTransaction
|
||
{
|
||
GatewayProvider = _configuration["PaymentProvider"] ?? "zarinpal",
|
||
MerchantId = _configuration["ZarinPal:MerchantId"] ?? "",
|
||
Amount = cmd.Amount,
|
||
CallbackUrl = cmd.CallbackUrl,
|
||
Description = cmd.Description,
|
||
UserId = cmd.UserId,
|
||
};
|
||
_context.PaymentTransactions.Add(tx);
|
||
await _context.SaveChangesAsync(ct);
|
||
|
||
// 2. ارسال به درگاه
|
||
var result = await _paymentGateway.InitiatePaymentAsync(
|
||
new PaymentRequest
|
||
{
|
||
Amount = cmd.Amount,
|
||
UserId = cmd.UserId,
|
||
CallbackUrl = cmd.CallbackUrl,
|
||
Description = cmd.Description,
|
||
}, ct);
|
||
|
||
// 3. آپدیت
|
||
tx.Authority = result.RefId;
|
||
tx.RequestStatusCode = result.IsSuccess ? 100 : -1;
|
||
await _context.SaveChangesAsync(ct);
|
||
|
||
return new PaymentResult { ... };
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Migration — جدول PaymentTransaction
|
||
|
||
```sql
|
||
CREATE TABLE PaymentTransactions (
|
||
Id BIGINT PRIMARY KEY IDENTITY,
|
||
|
||
-- Request Info
|
||
GatewayProvider NVARCHAR(50) NOT NULL DEFAULT 'zarinpal',
|
||
MerchantId NVARCHAR(100) NOT NULL,
|
||
Amount BIGINT NOT NULL, -- تومان
|
||
CallbackUrl NVARCHAR(500) NOT NULL,
|
||
Description NVARCHAR(500) NULL,
|
||
Mobile NVARCHAR(20) NULL,
|
||
UserId BIGINT NULL,
|
||
|
||
-- Request Response
|
||
RequestStatusCode INT NULL,
|
||
RequestStatusMessage NVARCHAR(500) NULL,
|
||
Authority NVARCHAR(100) NULL, -- INDEX ← سرچ Verify روی این
|
||
|
||
-- Payment Status
|
||
PaymentStatus BIT NOT NULL DEFAULT 0,
|
||
|
||
-- Verify Response
|
||
VerificationStatusCode INT NULL,
|
||
VerificationStatusMessage NVARCHAR(500) NULL,
|
||
CardHash NVARCHAR(200) NULL,
|
||
CardPan NVARCHAR(50) NULL,
|
||
RefId NVARCHAR(100) NULL, -- شماره پیگیری بانکی
|
||
|
||
-- Internal Links
|
||
TransactionId BIGINT NULL,
|
||
OrderId NVARCHAR(100) NULL,
|
||
|
||
-- Audit
|
||
Created DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||
LastModified DATETIME2 NULL,
|
||
CreatedBy NVARCHAR(100) NULL,
|
||
LastModifiedBy NVARCHAR(100) NULL
|
||
);
|
||
|
||
-- ایندکس روی Authority — برای Verify Lookup
|
||
CREATE INDEX IX_PaymentTransactions_Authority
|
||
ON PaymentTransactions(Authority);
|
||
```
|
||
|
||
**EF Core Fluent API:**
|
||
|
||
```csharp
|
||
builder.Entity<PaymentTransaction>(entity =>
|
||
{
|
||
entity.HasIndex(e => e.Authority);
|
||
entity.Property(e => e.GatewayProvider).HasMaxLength(50);
|
||
entity.Property(e => e.MerchantId).HasMaxLength(100);
|
||
entity.Property(e => e.Authority).HasMaxLength(100);
|
||
entity.Property(e => e.CardPan).HasMaxLength(50);
|
||
entity.Property(e => e.RefId).HasMaxLength(100);
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 12. چکلیست پیادهسازی در پروژه جدید
|
||
|
||
### مرحله ۱: آمادهسازی زیرساخت
|
||
- [ ] ثبتنام در [zarinpal.com](https://www.zarinpal.com) و دریافت MerchantId
|
||
- [ ] IP سرور رو در پنل زرینپال ثبت کنید (برای Production)
|
||
- [ ] Callback URL رو در پنل تنظیم کنید
|
||
|
||
### مرحله ۲: کد
|
||
- [ ] `IPaymentGatewayService` و DTOs رو در لایه Application ایجاد کنید
|
||
- [ ] `PaymentTransaction` Entity رو در لایه Domain ایجاد کنید
|
||
- [ ] `ZarinPalPaymentService` رو در لایه Infrastructure ایجاد کنید
|
||
- [ ] `MockPaymentGatewayService` رو ایجاد کنید
|
||
- [ ] DI Registration در `ConfigureServices.cs` (switch بر اساس `PaymentProvider`)
|
||
- [ ] Migration برای جدول `PaymentTransactions` اجرا کنید
|
||
- [ ] Callback Endpoint (REST یا gRPC) ایجاد کنید
|
||
|
||
### مرحله ۳: Configuration
|
||
- [ ] `appsettings.json` — تنظیمات Development (Sandbox)
|
||
- [ ] `appsettings.Production.json` — تنظیمات Production
|
||
- [ ] Environment variable یا K8s Secret برای MerchantId واقعی
|
||
|
||
### مرحله ۴: تست
|
||
- [ ] تست با Mock (بدون درگاه واقعی)
|
||
- [ ] تست با Sandbox زرینپال
|
||
- [ ] تست خطای شبکه (timeout, HTTP 5xx)
|
||
- [ ] تست Cancel — کاربر برمیگرده با `Status=NOK`
|
||
- [ ] تست Verify تکراری (Code 101)
|
||
- [ ] تست مبلغ صفر در Verify (باید خطا بده)
|
||
|
||
### مرحله ۵: Production
|
||
- [ ] `UseSandbox: false` تنظیم شه
|
||
- [ ] MerchantId واقعی تنظیم شه
|
||
- [ ] لاگها بررسی بشن (Authority, Amount, HTTP Status)
|
||
- [ ] مانیتورینگ تراکنشهای ناموفق
|
||
|
||
---
|
||
|
||
## 13. کدهای خطای زرینپال
|
||
|
||
### کدهای Request API
|
||
|
||
| Code | توضیح |
|
||
|------|-------|
|
||
| `100` | ✅ موفق — Authority ایجاد شد |
|
||
| `-9` | پارامترهای ورودی نامعتبر |
|
||
| `-10` | مرچنت آیدی نامعتبر |
|
||
| `-11` | مرچنت غیرفعال |
|
||
| `-12` | تلاش بیش از حد — بعداً تلاش کنید |
|
||
| `-15` | مبلغ کمتر از حد مجاز (حداقل ۱۰۰۰ ریال) |
|
||
| `-16` | سطح دسترسی مرچنت پایینتر از حد نقرهای |
|
||
| `-30` | مرچنت دسترسی به Settlement ندارد |
|
||
| `-31` | حساب بانکی متصل نیست |
|
||
| `-32` | مشکل اتصال حساب بانکی |
|
||
| `-33` | مبلغ بالاتر از حد مجاز |
|
||
| `-34` | محدودیت تعداد تراکنش |
|
||
| `-40` | دسترسی غیرمجاز به method |
|
||
| `-54` | درخواست آرشیو شده |
|
||
|
||
### کدهای Verify API
|
||
|
||
| Code | توضیح |
|
||
|------|-------|
|
||
| `100` | ✅ تأیید موفق |
|
||
| `101` | ✅ قبلاً تأیید شده (تکراری — حساب نشده) |
|
||
| `-1` | اطلاعات ناقص (معمولاً Amount=0) |
|
||
| `-50` | مبلغ ارسالی با مبلغ تراکنش مطابقت ندارد |
|
||
| `-51` | پرداخت ناموفق |
|
||
| `-52` | خطای غیرمنتظره |
|
||
| `-53` | Authority نامعتبر |
|
||
| `-54` | تراکنش آرشیو شده |
|
||
|
||
---
|
||
|
||
## 14. Sequence Diagram
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client (Browser)
|
||
participant S as Your Server
|
||
participant DB as Database
|
||
participant ZP as ZarinPal API
|
||
participant GW as ZarinPal Gateway
|
||
|
||
Note over C,GW: ─── مرحله ۱: ایجاد پرداخت ───
|
||
|
||
C->>S: POST /api/payment/initiate<br/>{amount: 50000, ...}
|
||
S->>DB: INSERT PaymentTransaction<br/>(amount=50000, provider=zarinpal)
|
||
|
||
S->>ZP: POST /pg/v4/payment/request.json<br/>{merchant_id, amount: 500000, callback_url}
|
||
Note right of S: ×10 تبدیل تومان به ریال
|
||
|
||
ZP-->>S: {code: 100, authority: "A0000...123"}
|
||
S->>DB: UPDATE PaymentTransaction<br/>SET Authority = "A0000...123"
|
||
S-->>C: {gatewayUrl: "https://zarinpal.com/pg/StartPay/A0000...123"}
|
||
|
||
Note over C,GW: ─── مرحله ۲: پرداخت در درگاه ───
|
||
|
||
C->>GW: Redirect → درگاه بانکی
|
||
GW->>GW: کاربر اطلاعات کارت وارد میکنه
|
||
GW-->>C: Redirect → callback?Authority=A0000...123&Status=OK
|
||
|
||
Note over C,GW: ─── مرحله ۳: تأیید پرداخت ───
|
||
|
||
C->>S: GET /api/payment/callback<br/>?Authority=A0000...123&Status=OK
|
||
|
||
S->>DB: SELECT * FROM PaymentTransactions<br/>WHERE Authority = "A0000...123"
|
||
DB-->>S: {amount: 50000, ...}
|
||
|
||
S->>ZP: POST /pg/v4/payment/verify.json<br/>{merchant_id, authority, amount: 500000}
|
||
Note right of S: ×10 تبدیل تومان به ریال
|
||
|
||
ZP-->>S: {code: 100, ref_id: 45678, card_pan: "6037-99**-****-1234"}
|
||
|
||
S->>DB: UPDATE PaymentTransaction<br/>SET PaymentStatus=true, RefId="45678",<br/>CardPan="6037-99**-****-1234"
|
||
|
||
S->>S: ✅ بیزینس لاجیک (شارژ کیف، ثبت سفارش، ...)
|
||
S-->>C: Redirect → /payment/success
|
||
```
|
||
|
||
---
|
||
|
||
## NuGet Packages مورد نیاز
|
||
|
||
```xml
|
||
<!-- فقط همینا نیاز هست — بدون پکیج اضافه! -->
|
||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.*" />
|
||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.*" />
|
||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.*" />
|
||
```
|
||
|
||
`System.Text.Json` و `System.Net.Http` جزو فریمورک هستن و پکیج جداگانه نمیخوان.
|
||
|
||
---
|
||
|
||
## خلاصه سریع
|
||
|
||
| چی | کجا |
|
||
|----|-----|
|
||
| Interface + DTOs | `Application/Common/Interfaces/IPaymentGatewayService.cs` |
|
||
| Entity | `Domain/Entities/Payment/PaymentTransaction.cs` |
|
||
| ZarinPal Service | `Infrastructure/Services/Payment/ZarinPalPaymentService.cs` |
|
||
| Mock Service | `Infrastructure/Services/Payment/MockPaymentGatewayService.cs` |
|
||
| DI Switch | `Infrastructure/ConfigureServices.cs` |
|
||
| Config | `appsettings.json` → `PaymentProvider` + `ZarinPal:*` |
|
||
| واحد پول | **تومان** — سرویس خودش ×10 میکنه |
|
||
| API Version | v4 (`/pg/v4/payment/...`) |
|
||
| JSON Naming | `snake_case` (`PropertyNamingPolicy.SnakeCaseLower`) |
|
||
| خطای رایج | Amount=0 در Verify → Code=-1 |
|
||
| خطای رایج | errors=[] vs errors={} → نیاز به Custom Converter |
|