diff --git a/docs/ZARINPAL-INTEGRATION-GUIDE.md b/docs/ZARINPAL-INTEGRATION-GUIDE.md
new file mode 100644
index 0000000..370d960
--- /dev/null
+++ b/docs/ZARINPAL-INTEGRATION-GUIDE.md
@@ -0,0 +1,1371 @@
+# راهنمای فنی پیادهسازی درگاه پرداخت زرینپال (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 |
diff --git a/src/CMSMicroservice.Application/CommissionCQ/Commands/TriggerWeeklyCalculation/TriggerWeeklyCalculationCommand.cs b/src/CMSMicroservice.Application/CommissionCQ/Commands/TriggerWeeklyCalculation/TriggerWeeklyCalculationCommand.cs
index c717361..b93298d 100644
--- a/src/CMSMicroservice.Application/CommissionCQ/Commands/TriggerWeeklyCalculation/TriggerWeeklyCalculationCommand.cs
+++ b/src/CMSMicroservice.Application/CommissionCQ/Commands/TriggerWeeklyCalculation/TriggerWeeklyCalculationCommand.cs
@@ -21,4 +21,9 @@ public record TriggerWeeklyCalculationCommand : IRequest
public bool SkipPayouts { get; init; }
+
+ ///
+ /// شناسه پکیج — اگر مقدار داشته باشد فقط این پکیج محاسبه میشود، در غیر این صورت همه پکیجها
+ ///
+ public long? PackageId { get; init; }
}
diff --git a/src/CMSMicroservice.Application/CommissionCQ/Commands/TriggerWeeklyCalculation/TriggerWeeklyCalculationCommandHandler.cs b/src/CMSMicroservice.Application/CommissionCQ/Commands/TriggerWeeklyCalculation/TriggerWeeklyCalculationCommandHandler.cs
index 2973d19..c7cf58e 100644
--- a/src/CMSMicroservice.Application/CommissionCQ/Commands/TriggerWeeklyCalculation/TriggerWeeklyCalculationCommandHandler.cs
+++ b/src/CMSMicroservice.Application/CommissionCQ/Commands/TriggerWeeklyCalculation/TriggerWeeklyCalculationCommandHandler.cs
@@ -53,6 +53,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler
///
public bool? OnlyCalculated { get; init; }
+ ///
+ /// فیلتر بر اساس پکیج (اختیاری)
+ ///
+ public long? PackageId { get; init; }
+
///
/// شماره صفحه
///
diff --git a/src/CMSMicroservice.Application/CommissionCQ/Queries/GetAllWeeklyPools/GetAllWeeklyPoolsQueryHandler.cs b/src/CMSMicroservice.Application/CommissionCQ/Queries/GetAllWeeklyPools/GetAllWeeklyPoolsQueryHandler.cs
index d49e89f..84b1608 100644
--- a/src/CMSMicroservice.Application/CommissionCQ/Queries/GetAllWeeklyPools/GetAllWeeklyPoolsQueryHandler.cs
+++ b/src/CMSMicroservice.Application/CommissionCQ/Queries/GetAllWeeklyPools/GetAllWeeklyPoolsQueryHandler.cs
@@ -17,6 +17,7 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler i.WeekDefinition)
+ .Include(i => i.Package)
.AsNoTracking();
// Apply filters
@@ -35,6 +36,11 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler x.IsCalculated);
}
+ if (request.PackageId.HasValue)
+ {
+ query = query.Where(x => x.PackageId == request.PackageId.Value);
+ }
+
// Order by week number descending (newest first)
query = query.OrderByDescending(x => x.WeekDefinitionId);
@@ -55,7 +61,9 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler
/// شماره هفته
///
public long WeekDefinitionId { get; init; }
+
+ ///
+ /// شناسه پکیج — اختیاری
+ ///
+ public long? PackageId { get; init; }
}
diff --git a/src/CMSMicroservice.Application/CommissionCQ/Queries/GetWeeklyCommissionPool/GetWeeklyCommissionPoolQueryHandler.cs b/src/CMSMicroservice.Application/CommissionCQ/Queries/GetWeeklyCommissionPool/GetWeeklyCommissionPoolQueryHandler.cs
index b6ec9b4..ebd6d85 100644
--- a/src/CMSMicroservice.Application/CommissionCQ/Queries/GetWeeklyCommissionPool/GetWeeklyCommissionPoolQueryHandler.cs
+++ b/src/CMSMicroservice.Application/CommissionCQ/Queries/GetWeeklyCommissionPool/GetWeeklyCommissionPoolQueryHandler.cs
@@ -15,29 +15,32 @@ public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
{
- var pool = await _context.WeeklyCommissionPools
+ var query = _context.WeeklyCommissionPools
.Include(x => x.WeekDefinition)
+ .Include(x => x.Package)
.AsNoTracking()
- .Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
+ .Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
+
+ if (request.PackageId.HasValue)
+ query = query.Where(x => x.PackageId == request.PackageId.Value);
+
+ var pool = await query
.Select(x => new WeeklyCommissionPoolDto
{
Id = x.Id,
WeekDefinitionId = x.WeekDefinitionId,
- WeekDisplayName=x.WeekDefinition.DisplayName,
+ WeekDisplayName = x.WeekDefinition.DisplayName,
TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance,
IsCalculated = x.IsCalculated,
CalculatedAt = x.CalculatedAt,
- Created = x.Created
+ Created = x.Created,
+ PackageId = x.PackageId,
+ PackageTitle = x.Package != null ? x.Package.Title : string.Empty
})
.FirstOrDefaultAsync(cancellationToken);
- // if (pool != null)
- // {
- // pool.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(pool.WeekNumber);
- // }
-
return pool ?? new WeeklyCommissionPoolDto();
}
}
diff --git a/src/CMSMicroservice.Application/CommissionCQ/Queries/GetWeeklyCommissionPool/WeeklyCommissionPoolDto.cs b/src/CMSMicroservice.Application/CommissionCQ/Queries/GetWeeklyCommissionPool/WeeklyCommissionPoolDto.cs
index 2463a7f..f21d481 100644
--- a/src/CMSMicroservice.Application/CommissionCQ/Queries/GetWeeklyCommissionPool/WeeklyCommissionPoolDto.cs
+++ b/src/CMSMicroservice.Application/CommissionCQ/Queries/GetWeeklyCommissionPool/WeeklyCommissionPoolDto.cs
@@ -14,4 +14,6 @@ public class WeeklyCommissionPoolDto
public bool IsCalculated { get; set; }
public DateTime? CalculatedAt { get; set; }
public DateTimeOffset Created { get; set; }
+ public long PackageId { get; set; }
+ public string PackageTitle { get; set; } = string.Empty;
}
diff --git a/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategy.cs b/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategy.cs
index a25bb7a..f9c8aba 100644
--- a/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategy.cs
+++ b/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategy.cs
@@ -11,11 +11,13 @@ public interface ICommissionCalculationStrategy
///
/// شناسه هفته
/// محاسبه مجدد حتی اگر قبلاً محاسبه شده
+ /// اگر مقدار داشته باشد فقط این پکیج محاسبه میشود — در غیر این صورت همه پکیجها
/// توکن لغو
/// تعداد رکوردهای محاسبه شده
Task CalculateWeeklyBalancesAsync(
long weekDefinitionId,
- bool forceRecalculate,
+ bool forceRecalculate,
+ long? packageId = null,
CancellationToken cancellationToken = default);
///
@@ -23,11 +25,13 @@ public interface ICommissionCalculationStrategy
///
/// شناسه هفته
/// محاسبه مجدد حتی اگر قبلاً محاسبه شده
+ /// اگر مقدار داشته باشد فقط این پکیج محاسبه میشود — در غیر این صورت همه پکیجها
/// توکن لغو
/// شناسه Pool ایجاد شده
Task CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
- bool forceRecalculate,
+ bool forceRecalculate,
+ long? packageId = null,
CancellationToken cancellationToken = default);
}
diff --git a/src/CMSMicroservice.Infrastructure/Services/Commission/OrmCommissionCalculationStrategy.cs b/src/CMSMicroservice.Infrastructure/Services/Commission/OrmCommissionCalculationStrategy.cs
index b6c4010..293f5ca 100644
--- a/src/CMSMicroservice.Infrastructure/Services/Commission/OrmCommissionCalculationStrategy.cs
+++ b/src/CMSMicroservice.Infrastructure/Services/Commission/OrmCommissionCalculationStrategy.cs
@@ -30,6 +30,7 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
public async Task CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
+ long? packageId = null,
CancellationToken cancellationToken = default)
{
var regorianWeekNumber = _weekRepository.GetGregorianWeekNumber(weekDefinitionId);
@@ -211,6 +212,7 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
public async Task CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
+ long? packageId = null,
CancellationToken cancellationToken = default)
{
var weekDefinition = _weekRepository.GetGregorianWeekNumber(weekDefinitionId);
diff --git a/src/CMSMicroservice.Infrastructure/Services/Commission/StoredProcedureCommissionCalculationStrategy.cs b/src/CMSMicroservice.Infrastructure/Services/Commission/StoredProcedureCommissionCalculationStrategy.cs
index 71ea602..b37459a 100644
--- a/src/CMSMicroservice.Infrastructure/Services/Commission/StoredProcedureCommissionCalculationStrategy.cs
+++ b/src/CMSMicroservice.Infrastructure/Services/Commission/StoredProcedureCommissionCalculationStrategy.cs
@@ -21,6 +21,7 @@ public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculati
public async Task CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
+ long? packageId = null,
CancellationToken cancellationToken = default)
{
var dbContext = _context as DbContext;
@@ -29,10 +30,11 @@ public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculati
throw new InvalidOperationException("DbContext برای اجرای Stored Procedure در دسترس نیست");
}
- // بارگذاری پکیجهای فعال
- var activePackages = await _context.Packages
- .Where(p => !p.IsDeleted && p.IsActive)
- .ToListAsync(cancellationToken);
+ // بارگذاری پکیجهای فعال — اگر packageId داده شده فقط همان پکیج
+ var packagesQuery = _context.Packages.Where(p => !p.IsDeleted && p.IsActive);
+ if (packageId.HasValue)
+ packagesQuery = packagesQuery.Where(p => p.Id == packageId.Value);
+ var activePackages = await packagesQuery.ToListAsync(cancellationToken);
if (!activePackages.Any())
{
@@ -109,6 +111,7 @@ public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculati
public async Task CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
+ long? packageId = null,
CancellationToken cancellationToken = default)
{
var dbContext = _context as DbContext;
@@ -117,38 +120,59 @@ public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculati
throw new InvalidOperationException("DbContext برای اجرای Stored Procedure در دسترس نیست");
}
+ // بارگذاری پکیجهای فعال — اگر packageId داده شده فقط همان پکیج
+ var packagesQuery = _context.Packages.Where(p => !p.IsDeleted && p.IsActive);
+ if (packageId.HasValue)
+ packagesQuery = packagesQuery.Where(p => p.Id == packageId.Value);
+ var activePackages = await packagesQuery.ToListAsync(cancellationToken);
+
+ if (!activePackages.Any())
+ {
+ throw new InvalidOperationException("هیچ پکیج فعالی یافت نشد");
+ }
+
var connection = dbContext.Database.GetDbConnection();
await connection.OpenAsync(cancellationToken);
+ long lastPoolId = 0;
+
try
{
- using var command = connection.CreateCommand();
- command.CommandText = "CMS.sp_CalculateWeeklyCommissionPool";
- command.CommandType = System.Data.CommandType.StoredProcedure;
- command.CommandTimeout = 300; // 5 دقیقه timeout
+ // حلقه روی پکیجها — هر پکیج Pool مستقل دارد
+ foreach (var package in activePackages)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = "CMS.sp_CalculateWeeklyCommissionPool";
+ command.CommandType = System.Data.CommandType.StoredProcedure;
+ command.CommandTimeout = 300;
- // پارامترها
- var paramWeekDefinitionId = command.CreateParameter();
- paramWeekDefinitionId.ParameterName = "@WeekDefinitionId";
- paramWeekDefinitionId.Value = weekDefinitionId;
- command.Parameters.Add(paramWeekDefinitionId);
+ var paramWeekDefinitionId = command.CreateParameter();
+ paramWeekDefinitionId.ParameterName = "@WeekDefinitionId";
+ paramWeekDefinitionId.Value = weekDefinitionId;
+ command.Parameters.Add(paramWeekDefinitionId);
- var paramForceRecalculate = command.CreateParameter();
- paramForceRecalculate.ParameterName = "@ForceRecalculate";
- paramForceRecalculate.Value = forceRecalculate;
- command.Parameters.Add(paramForceRecalculate);
+ var paramPackageId = command.CreateParameter();
+ paramPackageId.ParameterName = "@PackageId";
+ paramPackageId.Value = package.Id;
+ command.Parameters.Add(paramPackageId);
- // پارامتر خروجی - شناسه Pool
- var paramPoolId = command.CreateParameter();
- paramPoolId.ParameterName = "@PoolId";
- paramPoolId.Direction = System.Data.ParameterDirection.Output;
- paramPoolId.DbType = System.Data.DbType.Int64;
- command.Parameters.Add(paramPoolId);
+ var paramForceRecalculate = command.CreateParameter();
+ paramForceRecalculate.ParameterName = "@ForceRecalculate";
+ paramForceRecalculate.Value = forceRecalculate;
+ command.Parameters.Add(paramForceRecalculate);
- await command.ExecuteNonQueryAsync(cancellationToken);
+ var paramPoolId = command.CreateParameter();
+ paramPoolId.ParameterName = "@PoolId";
+ paramPoolId.Direction = System.Data.ParameterDirection.Output;
+ paramPoolId.DbType = System.Data.DbType.Int64;
+ command.Parameters.Add(paramPoolId);
- var poolId = (long)(paramPoolId.Value ?? 0L);
- return poolId;
+ await command.ExecuteNonQueryAsync(cancellationToken);
+
+ lastPoolId = (long)(paramPoolId.Value ?? 0L);
+ }
+
+ return lastPoolId;
}
finally
{
diff --git a/src/CMSMicroservice.Protobuf/Protos/commission.proto b/src/CMSMicroservice.Protobuf/Protos/commission.proto
index 9518c04..51c37bc 100644
--- a/src/CMSMicroservice.Protobuf/Protos/commission.proto
+++ b/src/CMSMicroservice.Protobuf/Protos/commission.proto
@@ -197,6 +197,7 @@ message RejectWithdrawalRequest
message GetWeeklyCommissionPoolRequest
{
int64 week_definition_id = 1;
+ google.protobuf.Int64Value package_id = 2; // اگر خالی باشد اولین pool هفته را برمیگرداند
}
message GetWeeklyCommissionPoolResponse
@@ -209,6 +210,8 @@ message GetWeeklyCommissionPoolResponse
bool is_calculated = 7;
google.protobuf.Timestamp calculated_at = 8;
google.protobuf.Timestamp created = 9;
+ int64 package_id = 10;
+ string package_title = 11;
}
// GetUserCommissionPayouts Query
@@ -357,8 +360,9 @@ message GetAllWeeklyPoolsRequest
google.protobuf.Int64Value from_week_definition_id = 1; // WeekDefinitionId filter (optional)
google.protobuf.Int64Value to_week_definition_id = 2; // WeekDefinitionId filter (optional)
google.protobuf.BoolValue only_calculated = 3; // Only show calculated pools
- int32 page_index = 4;
- int32 page_size = 5;
+ google.protobuf.Int64Value package_id = 4; // فیلتر بر اساس پکیج (optional)
+ int32 page_index = 5;
+ int32 page_size = 6;
}
message GetAllWeeklyPoolsResponse
@@ -378,6 +382,8 @@ message WeeklyCommissionPoolModel
bool is_calculated = 7;
google.protobuf.Timestamp calculated_at = 8;
google.protobuf.Timestamp created = 9;
+ int64 package_id = 10;
+ string package_title = 11;
}
// GetWithdrawalRequests Query
@@ -407,6 +413,7 @@ message TriggerWeeklyCalculationRequest
bool skip_balances = 3; // Skip balance calculation (only pool and payouts)
bool skip_pool = 4; // Skip pool calculation (only balances and payouts)
bool skip_payouts = 5; // Skip payout processing (only balances and pool)
+ google.protobuf.Int64Value package_id = 6; // اگر مقدار دارد فقط این پکیج محاسبه میشود - در غیر این صورت همه پکیجها
}
message TriggerWeeklyCalculationResponse
diff --git a/src/CMSMicroservice.WebApi/Common/Mappings/CommissionProfile.cs b/src/CMSMicroservice.WebApi/Common/Mappings/CommissionProfile.cs
index 03a9d8b..79b9144 100644
--- a/src/CMSMicroservice.WebApi/Common/Mappings/CommissionProfile.cs
+++ b/src/CMSMicroservice.WebApi/Common/Mappings/CommissionProfile.cs
@@ -1,4 +1,6 @@
using System.Linq;
+using CMSMicroservice.Application.CommissionCQ.Commands.TriggerWeeklyCalculation;
+using CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
using CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
using CMSMicroservice.Application.CommissionCQ.Queries.GetMyWeeklyBalances;
using CMSMicroservice.Application.CommissionCQ.Queries.GetAvailableWeeks;
@@ -23,6 +25,34 @@ public class CommissionProfile : IRegister
.Map(dest => dest.FutureWeeksCount, src => src.FutureWeeksCount > 0 ? src.FutureWeeksCount : 4)
.Map(dest => dest.PastWeeksCount, src => src.PastWeeksCount > 0 ? src.PastWeeksCount : 12);
+ // TriggerWeeklyCalculation Request Mapping (proto → command)
+ config.NewConfig()
+ .Map(dest => dest.WeekDefinitionId, src => src.WeekDefinitionId)
+ .Map(dest => dest.ForceRecalculate, src => src.ForceRecalculate)
+ .Map(dest => dest.SkipBalances, src => src.SkipBalances)
+ .Map(dest => dest.SkipPayouts, src => src.SkipPayouts)
+ .Map(dest => dest.PackageId, src => src.PackageId != null ? src.PackageId.Value : (long?)null);
+
+ // GetWeeklyCommissionPool Request Mapping
+ config.NewConfig()
+ .Map(dest => dest.WeekDefinitionId, src => src.WeekDefinitionId)
+ .Map(dest => dest.PackageId, src => src.PackageId != null ? src.PackageId.Value : (long?)null);
+
+ // GetWeeklyCommissionPool Response Mapping
+ config.NewConfig()
+ .Map(dest => dest.Id, src => src.Id)
+ .Map(dest => dest.WeekDefinitionId, src => src.WeekDefinitionId)
+ .Map(dest => dest.TotalPoolAmount, src => src.TotalPoolAmount)
+ .Map(dest => dest.TotalBalances, src => (int)src.TotalBalances)
+ .Map(dest => dest.ValuePerBalance, src => (long)src.ValuePerBalance)
+ .Map(dest => dest.IsCalculated, src => src.IsCalculated)
+ .Map(dest => dest.CalculatedAt, src => src.CalculatedAt.HasValue
+ ? Timestamp.FromDateTime(DateTime.SpecifyKind(src.CalculatedAt.Value, DateTimeKind.Utc))
+ : null)
+ .Map(dest => dest.Created, src => Timestamp.FromDateTimeOffset(src.Created))
+ .Map(dest => dest.PackageId, src => src.PackageId)
+ .Map(dest => dest.PackageTitle, src => src.PackageTitle);
+
// GetAvailableWeeks Response Mapping
config.NewConfig()
.Map(dest => dest.CurrentWeek, src => src.CurrentWeek)
@@ -91,6 +121,44 @@ public class CommissionProfile : IRegister
}
: null);
+ // GetAllWeeklyPools Request Mapping
+ config.NewConfig()
+ .Map(dest => dest.FromWeekDefinitionId, src => src.FromWeekDefinitionId != null ? src.FromWeekDefinitionId.Value : (long?)null)
+ .Map(dest => dest.ToWeekDefinitionId, src => src.ToWeekDefinitionId != null ? src.ToWeekDefinitionId.Value : (long?)null)
+ .Map(dest => dest.OnlyCalculated, src => src.OnlyCalculated != null ? src.OnlyCalculated.Value : (bool?)null)
+ .Map(dest => dest.PackageId, src => src.PackageId != null ? src.PackageId.Value : (long?)null)
+ .Map(dest => dest.PageIndex, src => src.PageIndex > 0 ? src.PageIndex : 1)
+ .Map(dest => dest.PageSize, src => src.PageSize > 0 ? src.PageSize : 10);
+
+ // GetAllWeeklyPools Response Mapping
+ config.NewConfig()
+ .Map(dest => dest.MetaData, src => src.MetaData != null
+ ? new CMSMicroservice.Protobuf.Protos.MetaData
+ {
+ TotalCount = src.MetaData.TotalCount,
+ PageSize = src.MetaData.PageSize,
+ CurrentPage = src.MetaData.CurrentPage,
+ TotalPage = src.MetaData.TotalPages
+ }
+ : new CMSMicroservice.Protobuf.Protos.MetaData())
+ .Map(dest => dest.Models, src => src.Models);
+
+ // WeeklyCommissionPoolDto → WeeklyCommissionPoolModel
+ config.NewConfig()
+ .Map(dest => dest.Id, src => src.Id)
+ .Map(dest => dest.WeekDefinitionId, src => src.WeekDefinitionId)
+ .Map(dest => dest.WeekDisplayName, src => src.WeekDisplayName)
+ .Map(dest => dest.TotalPoolAmount, src => src.TotalPoolAmount)
+ .Map(dest => dest.TotalBalances, src => src.TotalBalances)
+ .Map(dest => dest.ValuePerBalance, src => src.ValuePerBalance)
+ .Map(dest => dest.IsCalculated, src => src.IsCalculated)
+ .Map(dest => dest.CalculatedAt, src => src.CalculatedAt.HasValue
+ ? Timestamp.FromDateTime(DateTime.SpecifyKind(src.CalculatedAt.Value, DateTimeKind.Utc))
+ : null)
+ .Map(dest => dest.Created, src => Timestamp.FromDateTime(DateTime.SpecifyKind(src.Created, DateTimeKind.Utc)))
+ .Map(dest => dest.PackageId, src => src.PackageId)
+ .Map(dest => dest.PackageTitle, src => src.PackageTitle);
+
// GetWeekDefinitions Response Mapping
config.NewConfig()
.Map(dest => dest.TotalCount, src => src.TotalCount)