6f573c0a29
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2s
Gateway 11,200,000 toman and wallet credit 12,656,000 toman; multiply by 10 when posting to TwentyCard API. Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
12 KiB
C#
307 lines
12 KiB
C#
using System.Collections.Generic;
|
|
using System.Net.Http;
|
|
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).
|
|
/// مبلغ ورودی به تومان است. درخواستها بهصورت multipart/form-data ارسال میشوند.
|
|
/// </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 readonly string _defaultEmail;
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
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.");
|
|
_defaultEmail = configuration["TwentyCard:DefaultEmail"] ?? "info@kbs2.ir";
|
|
|
|
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
|
if (!_httpClient.DefaultRequestHeaders.Contains("Accept"))
|
|
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json");
|
|
_logger.LogInformation("TwentyCard payment service initialized. BaseUrl={BaseUrl}", baseUrl);
|
|
}
|
|
|
|
public async Task<PaymentInitiateResult> InitiatePaymentAsync(
|
|
PaymentRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
// ۲۰کارت amount را به ریال میگیرد (تومان × ۱۰)
|
|
var amountRial = ((long)request.Amount) * 10L;
|
|
var amount = amountRial.ToString();
|
|
var payerName = string.IsNullOrWhiteSpace(request.Name)
|
|
? (string.IsNullOrWhiteSpace(request.Mobile) ? "کاربر" : request.Mobile)
|
|
: request.Name.Trim();
|
|
var invoice = string.IsNullOrWhiteSpace(request.Invoice)
|
|
? request.UserId.ToString()
|
|
: request.Invoice;
|
|
|
|
using var content = BuildForm(new Dictionary<string, string>
|
|
{
|
|
["terminal_id"] = _terminalId,
|
|
["terminal_username"] = _terminalUsername,
|
|
["terminal_password"] = _terminalPassword,
|
|
["amount"] = amount,
|
|
["name"] = payerName,
|
|
["mobile"] = request.Mobile ?? string.Empty,
|
|
["invoice"] = invoice,
|
|
["callback"] = request.CallbackUrl,
|
|
["description"] = request.Description ?? string.Empty,
|
|
// بدون email درگاه ۲۰کارت بهجای validation، HTTP 500 برمیگرداند
|
|
["email"] = ResolveEmail(request.Email)
|
|
});
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.NationalId))
|
|
AddFormField(content, "national_id", request.NationalId);
|
|
|
|
_logger.LogInformation(
|
|
"TwentyCard payment request: Amount={AmountToman} Toman ({AmountRial} Rial), User={UserId}, Invoice={Invoice}",
|
|
(long)request.Amount, amountRial, request.UserId, invoice);
|
|
|
|
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
|
|
?? result?.Error?.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
|
|
};
|
|
}
|
|
|
|
using var content = BuildForm(new Dictionary<string, string>
|
|
{
|
|
["terminal_id"] = _terminalId,
|
|
["terminal_username"] = _terminalUsername,
|
|
["terminal_password"] = _terminalPassword,
|
|
["authority"] = refId,
|
|
["amount"] = (((long)amountInToman) * 10L).ToString()
|
|
});
|
|
|
|
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 static MultipartFormDataContent BuildForm(Dictionary<string, string> fields)
|
|
{
|
|
var form = new MultipartFormDataContent();
|
|
foreach (var (key, value) in fields)
|
|
AddFormField(form, key, value ?? string.Empty);
|
|
return form;
|
|
}
|
|
|
|
/// <summary>
|
|
/// شبیه curl --form: بدون Content-Type روی هر part ( بعضی بکاندهای PHP با charset=utf-8 میشکنند).
|
|
/// </summary>
|
|
private static void AddFormField(MultipartFormDataContent form, string name, string value)
|
|
{
|
|
var part = new StringContent(value ?? string.Empty);
|
|
part.Headers.ContentType = null;
|
|
form.Add(part, name);
|
|
}
|
|
|
|
private string ResolveEmail(string? email)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(email) && email.Contains('@', StringComparison.Ordinal))
|
|
return email.Trim();
|
|
return _defaultEmail;
|
|
}
|
|
|
|
private sealed class TwentyCardRequestResponse
|
|
{
|
|
public string? Message { get; set; }
|
|
public TwentyCardRequestData? Data { get; set; }
|
|
public TwentyCardErrorBody? Error { get; set; }
|
|
}
|
|
|
|
private sealed class TwentyCardErrorBody
|
|
{
|
|
public string? Code { get; set; }
|
|
public string? Message { get; set; }
|
|
}
|
|
|
|
private sealed class TwentyCardRequestData
|
|
{
|
|
[JsonPropertyName("authority")]
|
|
public string? Authority { get; set; }
|
|
|
|
[JsonPropertyName("redirectUrl")]
|
|
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; }
|
|
}
|
|
}
|