From 4d6d77531dd6ff9c4b6ff076fcdc9c6fa61418bf Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Fri, 24 Apr 2026 03:43:20 +0330 Subject: [PATCH] feat(commission): add per-package calculation support - SP sp_CalculateWeeklyBalances: added @PackageId, @InputMaxBalancesPerLeg, @InputMaxNetworkLevel params; filters by PackageId - SP sp_CalculateWeeklyCommissionPool: added @PackageId param; all queries filter by PackageId/WeeklyPoolId for isolation - ICommissionCalculationStrategy: added optional PackageId param to both methods - StoredProcedureCommissionCalculationStrategy: loops per-package for both Balance and Pool methods; filters by packageId if provided - OrmCommissionCalculationStrategy: signature updated to match interface - TriggerWeeklyCalculationCommand: added PackageId optional field - TriggerWeeklyCalculationCommandHandler: passes PackageId to strategy - GetWeeklyCommissionPoolQuery: added optional PackageId filter - GetWeeklyCommissionPoolQueryHandler: filters pool by PackageId if provided - GetAllWeeklyPoolsQuery/Handler/DTO: added PackageId filter + PackageTitle in response - Proto commission.proto: added package_id to TriggerWeeklyCalculationRequest, GetWeeklyCommissionPoolRequest, WeeklyCommissionPoolModel, GetWeeklyCommissionPoolResponse, GetAllWeeklyPoolsRequest - CommissionProfile: added explicit mappings for TriggerWeeklyCalculation, GetWeeklyCommissionPool, GetAllWeeklyPools Fixes: SP picks wrong pool when multiple packages per week Fixes: SP ignores PackageId on ForceRecalculate (now uses WeeklyPoolId) Fixes: Zero-balance pools not fully recorded (now sets TotalBalances=0, ValuePerBalance=0) --- docs/ZARINPAL-INTEGRATION-GUIDE.md | 1371 +++++++++++++++++ .../TriggerWeeklyCalculationCommand.cs | 5 + .../TriggerWeeklyCalculationCommandHandler.cs | 2 + .../GetAllWeeklyPoolsQuery.cs | 5 + .../GetAllWeeklyPoolsQueryHandler.cs | 10 +- .../GetAllWeeklyPoolsResponseDto.cs | 2 + .../GetWeeklyCommissionPoolQuery.cs | 5 + .../GetWeeklyCommissionPoolQueryHandler.cs | 21 +- .../WeeklyCommissionPoolDto.cs | 2 + .../ICommissionCalculationStrategy.cs | 8 +- .../OrmCommissionCalculationStrategy.cs | 2 + ...dProcedureCommissionCalculationStrategy.cs | 76 +- .../Protos/commission.proto | 11 +- .../Common/Mappings/CommissionProfile.cs | 68 + 14 files changed, 1548 insertions(+), 40 deletions(-) create mode 100644 docs/ZARINPAL-INTEGRATION-GUIDE.md 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)