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; /// /// درگاه پرداخت بیست‌کارت (20Card IPG Merchant API). /// مبلغ ورودی به تومان است. درخواست‌ها به‌صورت multipart/form-data ارسال می‌شوند. /// public class TwentyCardPaymentService : IPaymentGatewayService { private readonly HttpClient _httpClient; private readonly ILogger _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 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 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 { ["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(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 VerifyPaymentAsync( string refId, string verificationToken, CancellationToken cancellationToken = default) => throw new NotImplementedException("TwentyCard requires VerifyPaymentAsync with amount."); public async Task 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 { ["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(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 ProcessPayoutAsync( PayoutRequest request, CancellationToken cancellationToken = default) { return Task.FromResult(new PayoutResult { IsSuccess = false, Message = "واریز از طریق درگاه ۲۰کارت پشتیبانی نمی‌شود" }); } private static MultipartFormDataContent BuildForm(Dictionary fields) { var form = new MultipartFormDataContent(); foreach (var (key, value) in fields) AddFormField(form, key, value ?? string.Empty); return form; } /// /// شبیه curl --form: بدون Content-Type روی هر part ( بعضی بک‌اندهای PHP با charset=utf-8 می‌شکنند). /// 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; } } }