Files
CMS/docs/ZARINPAL-INTEGRATION-GUIDE.md
masoodafar-web 4d6d77531d
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m3s
feat(commission): add per-package calculation support
- 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)
2026-04-24 03:43:20 +03:30

1372 lines
54 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# راهنمای فنی پیاده‌سازی درگاه پرداخت زرین‌پال (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 |