- 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)
54 KiB
راهنمای فنی پیادهسازی درگاه پرداخت زرینپال (ZarinPal)
نسخه: 1.0
تاریخ: اسفند ۱۴۰۴
تکنولوژی: ASP.NET Core (.NET 9) — Clean Architecture — Strategy Pattern
API زرینپال: v4
فهرست مطالب
- معماری کلی
- ساختار فایلها و لایهها
- فلوی پرداخت (Payment Flow)
- پیادهسازی گامبهگام
- فلوی Initiate Payment (ایجاد پرداخت)
- فلوی Verify Payment (تأیید پرداخت)
- نکات مهم تومان/ریال
- مدیریت خطای API زرینپال
- Sandbox vs Production
- الگوی استفاده در Handler/Controller
- Migration — جدول PaymentTransaction
- چکلیست پیادهسازی در پروژه جدید
- کدهای خطای زرینپال
- 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 issuesMockPaymentGatewayServiceبا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 ایجاد کنیدPaymentTransactionEntity رو در لایه 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.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 |