Files
CMS/docs/ZARINPAL-INTEGRATION-GUIDE.md
T
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

54 KiB
Raw Blame History

راهنمای فنی پیاده‌سازی درگاه پرداخت زرین‌پال (ZarinPal)

نسخه: 1.0
تاریخ: اسفند ۱۴۰۴
تکنولوژی: ASP.NET Core (.NET 9) — Clean Architecture — Strategy Pattern
API زرین‌پال: v4


فهرست مطالب

  1. معماری کلی
  2. ساختار فایل‌ها و لایه‌ها
  3. فلوی پرداخت (Payment Flow)
  4. پیاده‌سازی گام‌به‌گام
  5. فلوی Initiate Payment (ایجاد پرداخت)
  6. فلوی Verify Payment (تأیید پرداخت)
  7. نکات مهم تومان/ریال
  8. مدیریت خطای API زرین‌پال
  9. Sandbox vs Production
  10. الگوی استفاده در Handler/Controller
  11. Migration — جدول PaymentTransaction
  12. چک‌لیست پیاده‌سازی در پروژه جدید
  13. کدهای خطای زرین‌پال
  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:

{
  "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):

{
  "PaymentProvider": "zarinpal",
  "ZarinPal": {
    "MerchantId": "your-real-merchant-id",
    "UseSandbox": false
  }
}

4.2 Interface — لایه Application

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

// ── درخواست ایجاد پرداخت ──
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 برای لاگ کردن تمام تراکنش‌های درگاه پرداخت در دیتابیسه:

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

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:

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 (برای تست)

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:

// 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 کنید تا مطمئن بشید.

// 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 داره:

وقتی موفقه:

{
  "data": {
    "code": 100,
    "message": "Success",
    "authority": "A00000000000000000000000000123456",
    "fee_type": "Merchant",
    "fee": 0
  },
  "errors": []           آرایه خالی! (نه object)
}

وقتی خطا داره:

{
  "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)

[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

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

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:

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 و دریافت 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

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 مورد نیاز

<!-- فقط همینا نیاز هست — بدون پکیج اضافه! -->
<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.jsonPaymentProvider + ZarinPal:*
واحد پول تومان — سرویس خودش ×10 میکنه
API Version v4 (/pg/v4/payment/...)
JSON Naming snake_case (PropertyNamingPolicy.SnakeCaseLower)
خطای رایج Amount=0 در Verify → Code=-1
خطای رایج errors=[] vs errors={} → نیاز به Custom Converter