# راهنمای فنی پیاده‌سازی درگاه پرداخت زرین‌پال (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 |