# راهنمای فنی پیادهسازی درگاه پرداخت زرینپال (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;
///
/// Interface یکپارچه برای درگاههای پرداخت — Strategy Pattern
///
public interface IPaymentGatewayService
{
///
/// مرحله ۱: ارسال درخواست پرداخت و دریافت لینک درگاه
///
Task InitiatePaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken = default);
///
/// مرحله ۲: تأیید پرداخت بعد از بازگشت کاربر از درگاه (بدون مبلغ)
///
Task VerifyPaymentAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken = default);
///
/// مرحله ۲ (نسخه با مبلغ): تأیید پرداخت — زرینپال مبلغ رو در Verify نیاز داره
/// ⚠ این overload رو برای زرینپال حتماً استفاده کنید
///
Task VerifyPaymentAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken = default)
{
// Default implementation — throws اگه provider پیادهسازی نکنه
throw new NotImplementedException(
"درگاه پرداخت باید متد VerifyPaymentAsync با مبلغ را پیادهسازی کند");
}
///
/// واریز (Payout) — زرینپال ساپورت نمیکنه
///
Task 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
{
/// مبلغ به تومان (سرویس خودش ×10 میکنه برای ریال)
public decimal Amount { get; set; }
public long UserId { get; set; }
public string Mobile { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
/// آدرسی که زرینپال بعد از پرداخت کاربر رو Redirect میکنه
public string CallbackUrl { get; set; } = string.Empty;
}
// ── نتیجه ایجاد پرداخت ──
public class PaymentInitiateResult
{
public bool IsSuccess { get; set; }
/// Authority — شناسه یکتای تراکنش در زرینپال
public string? RefId { get; set; }
/// URL درگاه برای Redirect کاربر
public string? GatewayUrl { get; set; }
public string? ErrorMessage { get; set; }
}
// ── نتیجه تأیید پرداخت ──
public class PaymentVerificationResult
{
public bool IsSuccess { get; set; }
/// Authority
public string RefId { get; set; } = string.Empty;
/// شماره پیگیری بانکی (RefId عددی از زرینپال)
public string? TrackingCode { get; set; }
/// مبلغ تأیید شده (به تومان)
public decimal Amount { get; set; }
public string? Message { get; set; }
/// شماره کارت ماسکشده (مثال: 6037-99**-****-1234)
public string? CardPan { get; set; }
/// هش کارت پرداختکننده
public string? CardHash { get; set; }
/// کد وضعیت (100=موفق, 101=قبلاً تأیید شده)
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;
///
/// ذخیره تراکنشهای درگاه پرداخت — یک رکورد برای هر درخواست پرداخت
///
public class PaymentTransaction : BaseAuditableEntity
{
// ── اطلاعات درخواست (مرحله Request) ──
/// نام درگاه (zarinpal, mock, ...)
public string GatewayProvider { get; set; } = string.Empty;
/// مرچنت آیدی استفادهشده
public string MerchantId { get; set; } = string.Empty;
/// مبلغ تراکنش (به تومان)
public long Amount { get; set; }
/// Callback URL ارسالشده به درگاه
public string CallbackUrl { get; set; } = string.Empty;
/// توضیح تراکنش
public string Description { get; set; } = string.Empty;
/// شماره موبایل کاربر
public string? Mobile { get; set; }
/// شناسه کاربر
public long? UserId { get; set; }
// ── پاسخ Request API ──
/// کد وضعیت از Request API (100=موفق)
public int? RequestStatusCode { get; set; }
public string? RequestStatusMessage { get; set; }
/// Authority — شناسه یکتای تراکنش در زرینپال
public string? Authority { get; set; }
// ── وضعیت نهایی ──
/// آیا پرداخت موفق بود؟
public bool PaymentStatus { get; set; }
// ── نتیجه Verify API ──
/// کد وضعیت از Verify API (100=موفق, 101=تکراری)
public int? VerificationStatusCode { get; set; }
public string? VerificationStatusMessage { get; set; }
/// هش کارت بانکی
public string? CardHash { get; set; }
/// شماره کارت ماسکشده
public string? CardPan { get; set; }
/// شماره پیگیری بانکی
public string? RefId { get; set; }
// ── ارتباط با سیستم داخلی ──
/// شناسه تراکنش داخلی (اختیاری)
public long? TransactionId { get; set; }
/// شناسه سفارش (اختیاری)
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 _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 logger)
{
_httpClient = httpClient;
_logger = logger;
_merchantId = configuration["ZarinPal:MerchantId"]
?? throw new InvalidOperationException("ZarinPal:MerchantId is not configured.");
_useSandbox = configuration.GetValue("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 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(
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
// ═══════════════════════════════════════════════════════
/// Verify بدون مبلغ — سازگاری با interface (ممکنه fail بشه!)
public Task VerifyPaymentAsync(
string refId, string verificationToken,
CancellationToken cancellationToken = default)
{
_logger.LogWarning("VerifyPayment called without amount — may fail!");
return VerifyPaymentWithAmountAsync(
refId, verificationToken, 0, cancellationToken);
}
///
/// Verify با مبلغ — نسخه اصلی
/// refId = Authority, verificationToken = "OK"/"NOK", amount = تومان
///
public Task VerifyPaymentAsync(
string refId, string verificationToken, decimal amount,
CancellationToken cancellationToken = default)
{
return VerifyPaymentWithAmountAsync(
refId, verificationToken, amount, cancellationToken);
}
private async Task 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(
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}"
};
}
}
/// زرینپال Payout ندارد
public Task 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
{
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(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("PaymentProvider", "Mock")
?.ToLowerInvariant();
switch (paymentProvider)
{
case "zarinpal":
services.AddHttpClient()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
break;
case "mock":
default:
services.AddScoped();
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 _logger;
public MockPaymentGatewayService(ILogger logger)
{
_logger = logger;
}
public async Task 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 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 VerifyPaymentAsync(
string refId, string token, decimal amount, CancellationToken ct = default)
=> VerifyPaymentAsync(refId, token, ct);
public async Task 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 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 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
{
private readonly IPaymentGatewayService _paymentGateway;
private readonly IApplicationDbContext _context;
private readonly IConfiguration _configuration;
public async Task 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(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
{amount: 50000, ...}
S->>DB: INSERT PaymentTransaction
(amount=50000, provider=zarinpal)
S->>ZP: POST /pg/v4/payment/request.json
{merchant_id, amount: 500000, callback_url}
Note right of S: ×10 تبدیل تومان به ریال
ZP-->>S: {code: 100, authority: "A0000...123"}
S->>DB: UPDATE PaymentTransaction
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
?Authority=A0000...123&Status=OK
S->>DB: SELECT * FROM PaymentTransactions
WHERE Authority = "A0000...123"
DB-->>S: {amount: 50000, ...}
S->>ZP: POST /pg/v4/payment/verify.json
{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
SET PaymentStatus=true, RefId="45678",
CardPan="6037-99**-****-1234"
S->>S: ✅ بیزینس لاجیک (شارژ کیف، ثبت سفارش، ...)
S-->>C: Redirect → /payment/success
```
---
## NuGet Packages مورد نیاز
```xml
```
`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 |