feat(payment): implement multi-gateway support and enhance payment processing
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m44s
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:
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user