feat(payment): implement multi-gateway support and enhance payment processing
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m44s

- Added support for multiple payment gateways, including ZarinPal and TwentyCard, through a new IPaymentGatewayResolver.
- Updated PaymentRequest DTO to include payer name and invoice number for better transaction tracking.
- Enhanced PackageService to dynamically determine payment amounts based on the selected gateway.
- Updated appsettings to include configuration for TwentyCard payment options.
- Bumped Protobuf project version to reflect the addition of new features and changes.
This commit is contained in:
masoodafar-web
2026-09-20 22:17:55 +03:30
parent 78d1fee89a
commit 0b7212ac1c
13 changed files with 447 additions and 34 deletions
@@ -0,0 +1,264 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Infrastructure.Services.Payment;
/// <summary>
/// درگاه پرداخت بیست‌کارت (20Card IPG Merchant API).
/// مبلغ ورودی به تومان است (مثل بقیهٔ سیستم).
/// </summary>
public class TwentyCardPaymentService : IPaymentGatewayService
{
private readonly HttpClient _httpClient;
private readonly ILogger<TwentyCardPaymentService> _logger;
private readonly string _terminalId;
private readonly string _terminalUsername;
private readonly string _terminalPassword;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public TwentyCardPaymentService(
HttpClient httpClient,
IConfiguration configuration,
ILogger<TwentyCardPaymentService> logger)
{
_httpClient = httpClient;
_logger = logger;
var baseUrl = configuration["TwentyCard:BaseUrl"]
?? throw new InvalidOperationException("TwentyCard:BaseUrl is not configured.");
_terminalId = configuration["TwentyCard:TerminalId"]
?? throw new InvalidOperationException("TwentyCard:TerminalId is not configured.");
_terminalUsername = configuration["TwentyCard:TerminalUsername"]
?? throw new InvalidOperationException("TwentyCard:TerminalUsername is not configured.");
_terminalPassword = configuration["TwentyCard:TerminalPassword"]
?? throw new InvalidOperationException("TwentyCard:TerminalPassword is not configured.");
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
_logger.LogInformation("TwentyCard payment service initialized. BaseUrl={BaseUrl}", baseUrl);
}
public async Task<PaymentInitiateResult> InitiatePaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken = default)
{
try
{
var amount = (long)request.Amount;
var payerName = string.IsNullOrWhiteSpace(request.Name)
? (string.IsNullOrWhiteSpace(request.Mobile) ? "کاربر" : request.Mobile)
: request.Name.Trim();
var body = new Dictionary<string, object?>
{
["terminal_id"] = _terminalId,
["terminal_username"] = _terminalUsername,
["terminal_password"] = _terminalPassword,
["amount"] = amount,
["name"] = payerName,
["mobile"] = request.Mobile ?? string.Empty,
["invoice"] = string.IsNullOrWhiteSpace(request.Invoice)
? request.UserId.ToString()
: request.Invoice,
["callback"] = request.CallbackUrl,
["description"] = request.Description
};
_logger.LogInformation(
"TwentyCard payment request: Amount={Amount} Toman, User={UserId}, Invoice={Invoice}",
amount, request.UserId, body["invoice"]);
using var content = new StringContent(
JsonSerializer.Serialize(body, JsonOptions), Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("api/pgw/merchant/request", content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
"TwentyCard HTTP error on request: StatusCode={StatusCode}, Body={Body}",
(int)response.StatusCode, responseBody);
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطای ارتباط با درگاه ۲۰کارت (HTTP {(int)response.StatusCode})"
};
}
var result = JsonSerializer.Deserialize<TwentyCardRequestResponse>(responseBody, JsonOptions);
var authority = result?.Data?.Authority;
var redirectUrl = result?.Data?.RedirectUrl;
if (string.IsNullOrWhiteSpace(authority) || string.IsNullOrWhiteSpace(redirectUrl))
{
_logger.LogError("TwentyCard request failed. Body={Body}", responseBody);
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = result?.Message ?? "ایجاد تراکنش ۲۰کارت ناموفق بود"
};
}
return new PaymentInitiateResult
{
IsSuccess = true,
RefId = authority,
GatewayUrl = redirectUrl
};
}
catch (Exception ex)
{
_logger.LogError(ex, "TwentyCard InitiatePayment failed");
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = "خطا در ارتباط با درگاه ۲۰کارت"
};
}
}
public Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken = default)
=> throw new NotImplementedException("TwentyCard requires VerifyPaymentAsync with amount.");
public async Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken = default)
{
try
{
if (!string.Equals(verificationToken, "OK", StringComparison.OrdinalIgnoreCase))
{
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = "پرداخت توسط کاربر لغو شد یا ناموفق بود",
VerificationCode = 0
};
}
var body = new Dictionary<string, object?>
{
["terminal_id"] = _terminalId,
["terminal_username"] = _terminalUsername,
["terminal_password"] = _terminalPassword,
["authority"] = refId,
["amount"] = (long)amountInToman
};
using var content = new StringContent(
JsonSerializer.Serialize(body, JsonOptions), Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("api/pgw/merchant/verify", content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogInformation(
"TwentyCard verify response: Authority={Authority}, StatusCode={StatusCode}, Body={Body}",
refId, (int)response.StatusCode, responseBody);
var result = JsonSerializer.Deserialize<TwentyCardVerifyResponse>(responseBody, JsonOptions);
var paid = result?.Status?.Paid == true;
var verified = result?.Status?.Verified == true;
var noError = result?.Error != true;
if (response.IsSuccessStatusCode && noError && paid && verified)
{
return new PaymentVerificationResult
{
IsSuccess = true,
RefId = refId,
TrackingCode = refId,
Message = result?.Message ?? "تراکنش با موفقیت تایید شد",
VerificationCode = result?.Code ?? 200
};
}
// Code 103 = already verified — treat as success for idempotency
if (result?.Code == 103)
{
return new PaymentVerificationResult
{
IsSuccess = true,
RefId = refId,
TrackingCode = refId,
Message = result.Message ?? "تراکنش قبلاً تایید شده است",
VerificationCode = 103
};
}
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = result?.Message ?? "تایید تراکنش ۲۰کارت ناموفق بود",
VerificationCode = result?.Code ?? (int)response.StatusCode
};
}
catch (Exception ex)
{
_logger.LogError(ex, "TwentyCard VerifyPayment failed. Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = "خطا در تایید تراکنش ۲۰کارت"
};
}
}
public Task<PayoutResult> ProcessPayoutAsync(
PayoutRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new PayoutResult
{
IsSuccess = false,
Message = "واریز از طریق درگاه ۲۰کارت پشتیبانی نمی‌شود"
});
}
private sealed class TwentyCardRequestResponse
{
public string? Message { get; set; }
public TwentyCardRequestData? Data { get; set; }
}
private sealed class TwentyCardRequestData
{
public string? Authority { get; set; }
public string? RedirectUrl { get; set; }
}
private sealed class TwentyCardVerifyResponse
{
public bool Error { get; set; }
public int Code { get; set; }
public string? Message { get; set; }
public TwentyCardVerifyStatus? Status { get; set; }
}
private sealed class TwentyCardVerifyStatus
{
public bool Paid { get; set; }
public bool Verified { get; set; }
}
}