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:
@@ -15,6 +15,7 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||||
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
|
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
|
||||||
<PackageReference Include="DateTimeConverterCL" Version="1.0.0" />
|
<PackageReference Include="DateTimeConverterCL" Version="1.0.0" />
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve payment gateway by provider key (zarinpal, 20card, …).
|
||||||
|
/// </summary>
|
||||||
|
public interface IPaymentGatewayResolver
|
||||||
|
{
|
||||||
|
IPaymentGatewayService Get(string? provider);
|
||||||
|
}
|
||||||
@@ -98,6 +98,16 @@ public class PaymentRequest
|
|||||||
/// URL بازگشت بعد از پرداخت
|
/// URL بازگشت بعد از پرداخت
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string CallbackUrl { get; set; } = string.Empty;
|
public string CallbackUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نام پرداختکننده (برای درگاههایی مثل ۲۰کارت)
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شماره فاکتور / سفارش
|
||||||
|
/// </summary>
|
||||||
|
public string Invoice { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+26
-8
@@ -8,6 +8,7 @@ using CMSMicroservice.Domain.Entities.Payment;
|
|||||||
using CMSMicroservice.Domain.Enums;
|
using CMSMicroservice.Domain.Enums;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyUserPackagePurchasePayment;
|
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyUserPackagePurchasePayment;
|
||||||
@@ -16,20 +17,23 @@ public class VerifyUserPackagePurchasePaymentCommandHandler
|
|||||||
: IRequestHandler<VerifyUserPackagePurchasePaymentCommand, VerifyUserPackagePurchasePaymentResult>
|
: IRequestHandler<VerifyUserPackagePurchasePaymentCommand, VerifyUserPackagePurchasePaymentResult>
|
||||||
{
|
{
|
||||||
private readonly IApplicationDbContext _context;
|
private readonly IApplicationDbContext _context;
|
||||||
private readonly IPaymentGatewayService _paymentGateway;
|
private readonly IPaymentGatewayResolver _paymentGatewayResolver;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ISender _sender;
|
private readonly ISender _sender;
|
||||||
private readonly IUserPaymentLock _paymentLock;
|
private readonly IUserPaymentLock _paymentLock;
|
||||||
private readonly ILogger<VerifyUserPackagePurchasePaymentCommandHandler> _logger;
|
private readonly ILogger<VerifyUserPackagePurchasePaymentCommandHandler> _logger;
|
||||||
|
|
||||||
public VerifyUserPackagePurchasePaymentCommandHandler(
|
public VerifyUserPackagePurchasePaymentCommandHandler(
|
||||||
IApplicationDbContext context,
|
IApplicationDbContext context,
|
||||||
IPaymentGatewayService paymentGateway,
|
IPaymentGatewayResolver paymentGatewayResolver,
|
||||||
|
IConfiguration configuration,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
IUserPaymentLock paymentLock,
|
IUserPaymentLock paymentLock,
|
||||||
ILogger<VerifyUserPackagePurchasePaymentCommandHandler> logger)
|
ILogger<VerifyUserPackagePurchasePaymentCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_paymentGateway = paymentGateway;
|
_paymentGatewayResolver = paymentGatewayResolver;
|
||||||
|
_configuration = configuration;
|
||||||
_sender = sender;
|
_sender = sender;
|
||||||
_paymentLock = paymentLock;
|
_paymentLock = paymentLock;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -105,7 +109,8 @@ public class VerifyUserPackagePurchasePaymentCommandHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
var amountInToman = paymentTx?.Amount ?? purchase.Amount;
|
var amountInToman = paymentTx?.Amount ?? purchase.Amount;
|
||||||
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
|
var gateway = _paymentGatewayResolver.Get(paymentTx?.GatewayProvider);
|
||||||
|
var verifyResult = await gateway.VerifyPaymentAsync(
|
||||||
request.Authority, request.Status, (decimal)amountInToman, ct);
|
request.Authority, request.Status, (decimal)amountInToman, ct);
|
||||||
|
|
||||||
if (!verifyResult.IsSuccess)
|
if (!verifyResult.IsSuccess)
|
||||||
@@ -193,17 +198,30 @@ public class VerifyUserPackagePurchasePaymentCommandHandler
|
|||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
var discountMultiplier = purchase.Package?.DiscountMultiplier
|
var packagePrice = purchase.Package?.Price
|
||||||
?? throw new InvalidOperationException("پکیج سفارش یافت نشد");
|
?? throw new InvalidOperationException("پکیج سفارش یافت نشد");
|
||||||
var discountAmount = (long)(purchase.Amount * (double)discountMultiplier);
|
var discountMultiplier = purchase.Package.DiscountMultiplier;
|
||||||
wallet.Balance += purchase.Amount;
|
var discountAmount = (long)(packagePrice * (double)discountMultiplier);
|
||||||
|
|
||||||
|
var isTwentyCard = string.Equals(
|
||||||
|
paymentTx?.GatewayProvider, "20card", StringComparison.OrdinalIgnoreCase);
|
||||||
|
long balanceCredit = purchase.Amount;
|
||||||
|
if (isTwentyCard)
|
||||||
|
{
|
||||||
|
balanceCredit = _configuration.GetValue<long?>(
|
||||||
|
$"TwentyCard:Packages:{purchase.PackageId}:WalletCreditToman") ?? 0;
|
||||||
|
if (balanceCredit <= 0)
|
||||||
|
throw new InvalidOperationException("WalletCredit ۲۰کارت برای این پکیج پیکربندی نشده است");
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet.Balance += balanceCredit;
|
||||||
wallet.DiscountBalance += discountAmount;
|
wallet.DiscountBalance += discountAmount;
|
||||||
|
|
||||||
_context.UserWalletHistories.Add(new UserWalletHistory
|
_context.UserWalletHistories.Add(new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
ChangeValue = purchase.Amount,
|
ChangeValue = balanceCredit,
|
||||||
CurrentNetworkBalance = wallet.NetworkBalance,
|
CurrentNetworkBalance = wallet.NetworkBalance,
|
||||||
ChangeNerworkValue = 0,
|
ChangeNerworkValue = 0,
|
||||||
CurrentDiscountBalance = wallet.DiscountBalance,
|
CurrentDiscountBalance = wallet.DiscountBalance,
|
||||||
|
|||||||
@@ -77,20 +77,24 @@ public static class ConfigureServices
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment Gateway Service - Multi-Provider Architecture
|
// Payment Gateway — default provider + multi-gateway resolver (ZarinPal + TwentyCard coexist)
|
||||||
// پشتیبانی از درگاههای مختلف: ZarinPal, Daya, Mock
|
|
||||||
var paymentProvider = configuration.GetValue<string>("PaymentProvider", "Mock")?.ToLowerInvariant();
|
var paymentProvider = configuration.GetValue<string>("PaymentProvider", "Mock")?.ToLowerInvariant();
|
||||||
|
|
||||||
|
services.AddHttpClient<ZarinPalPaymentService>()
|
||||||
|
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
|
||||||
|
services.AddHttpClient<TwentyCardPaymentService>()
|
||||||
|
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
switch (paymentProvider)
|
switch (paymentProvider)
|
||||||
{
|
{
|
||||||
case "zarinpal":
|
case "zarinpal":
|
||||||
services.AddHttpClient<IPaymentGatewayService, ZarinPalPaymentService>()
|
services.AddScoped<IPaymentGatewayService>(sp => sp.GetRequiredService<ZarinPalPaymentService>());
|
||||||
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "daya":
|
case "daya":
|
||||||
services.AddHttpClient<IPaymentGatewayService, DayaPaymentService>()
|
services.AddHttpClient<DayaPaymentService>()
|
||||||
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
|
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
|
||||||
|
services.AddScoped<IPaymentGatewayService>(sp => sp.GetRequiredService<DayaPaymentService>());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "mock":
|
case "mock":
|
||||||
@@ -99,6 +103,8 @@ public static class ConfigureServices
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
services.AddScoped<IPaymentGatewayResolver, PaymentGatewayResolver>();
|
||||||
|
|
||||||
services.AddScoped<IApplicationDbContext>(p => p.GetRequiredService<ApplicationDbContext>());
|
services.AddScoped<IApplicationDbContext>(p => p.GetRequiredService<ApplicationDbContext>());
|
||||||
|
|
||||||
// Week Definition Repository - کش در حافظه برای هفتهها (Singleton برای کش، با IServiceScopeFactory برای دسترسی به DbContext)
|
// Week Definition Repository - کش در حافظه برای هفتهها (Singleton برای کش، با IServiceScopeFactory برای دسترسی به DbContext)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Infrastructure.Services.Payment;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Infrastructure.Services.Payment;
|
||||||
|
|
||||||
|
public class PaymentGatewayResolver : IPaymentGatewayResolver
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
public PaymentGatewayResolver(IServiceProvider serviceProvider, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IPaymentGatewayService Get(string? provider)
|
||||||
|
{
|
||||||
|
var key = Normalize(provider)
|
||||||
|
?? Normalize(_configuration["PaymentProvider"])
|
||||||
|
?? "zarinpal";
|
||||||
|
|
||||||
|
return key switch
|
||||||
|
{
|
||||||
|
"20card" or "twentycard" => _serviceProvider.GetRequiredService<TwentyCardPaymentService>(),
|
||||||
|
"zarinpal" => _serviceProvider.GetRequiredService<ZarinPalPaymentService>(),
|
||||||
|
_ => _serviceProvider.GetRequiredService<IPaymentGatewayService>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Normalize(string? value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>0.0.211</Version>
|
<Version>0.0.212</Version>
|
||||||
<DebugType>None</DebugType>
|
<DebugType>None</DebugType>
|
||||||
<DebugSymbols>False</DebugSymbols>
|
<DebugSymbols>False</DebugSymbols>
|
||||||
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
|
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
|
||||||
|
|||||||
@@ -249,6 +249,8 @@ message CustomerPurchasePackageRequest
|
|||||||
int64 package_id = 1;
|
int64 package_id = 1;
|
||||||
PurchaseMethodEnum purchase_method = 2;
|
PurchaseMethodEnum purchase_method = 2;
|
||||||
string callback_url = 3;
|
string callback_url = 3;
|
||||||
|
// "zarinpal" (default) | "20card"
|
||||||
|
string gateway_provider = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CustomerPurchasePackageResponse
|
message CustomerPurchasePackageResponse
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public class PackageService : PackageContract.PackageContractBase
|
|||||||
private readonly ISender _sender;
|
private readonly ISender _sender;
|
||||||
private readonly IApplicationDbContext _context;
|
private readonly IApplicationDbContext _context;
|
||||||
private readonly ICurrentUserService _currentUserService;
|
private readonly ICurrentUserService _currentUserService;
|
||||||
private readonly IPaymentGatewayService _paymentGateway;
|
private readonly IPaymentGatewayResolver _paymentGatewayResolver;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IUserPaymentLock _paymentLock;
|
private readonly IUserPaymentLock _paymentLock;
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ public class PackageService : PackageContract.PackageContractBase
|
|||||||
ISender sender,
|
ISender sender,
|
||||||
IApplicationDbContext context,
|
IApplicationDbContext context,
|
||||||
ICurrentUserService currentUserService,
|
ICurrentUserService currentUserService,
|
||||||
IPaymentGatewayService paymentGateway,
|
IPaymentGatewayResolver paymentGatewayResolver,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IUserPaymentLock paymentLock)
|
IUserPaymentLock paymentLock)
|
||||||
{
|
{
|
||||||
@@ -49,7 +49,7 @@ public class PackageService : PackageContract.PackageContractBase
|
|||||||
_sender = sender;
|
_sender = sender;
|
||||||
_context = context;
|
_context = context;
|
||||||
_currentUserService = currentUserService;
|
_currentUserService = currentUserService;
|
||||||
_paymentGateway = paymentGateway;
|
_paymentGatewayResolver = paymentGatewayResolver;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_paymentLock = paymentLock;
|
_paymentLock = paymentLock;
|
||||||
}
|
}
|
||||||
@@ -172,18 +172,37 @@ public class PackageService : PackageContract.PackageContractBase
|
|||||||
if (package == null)
|
if (package == null)
|
||||||
throw new RpcException(new Status(StatusCode.NotFound, "پکیج مورد نظر یافت نشد"));
|
throw new RpcException(new Status(StatusCode.NotFound, "پکیج مورد نظر یافت نشد"));
|
||||||
|
|
||||||
// Create transaction
|
var gatewayProvider = NormalizeGatewayProvider(request.GatewayProvider);
|
||||||
|
var isTwentyCard = gatewayProvider == "20card";
|
||||||
|
|
||||||
|
long gatewayAmount = package.Price;
|
||||||
|
if (isTwentyCard)
|
||||||
|
{
|
||||||
|
var packageKey = $"TwentyCard:Packages:{package.Id}";
|
||||||
|
gatewayAmount = _configuration.GetValue<long?>($"{packageKey}:GatewayAmountToman") ?? 0;
|
||||||
|
var walletCredit = _configuration.GetValue<long?>($"{packageKey}:WalletCreditToman") ?? 0;
|
||||||
|
if (gatewayAmount <= 0 || walletCredit <= 0)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.FailedPrecondition,
|
||||||
|
"پرداخت ۲۰کارت برای این پکیج پیکربندی نشده است"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create transaction — 20card: Amount = gateway charge; zarinpal: Package.Price
|
||||||
var transaction = new CMSMicroservice.Domain.Entities.Transaction
|
var transaction = new CMSMicroservice.Domain.Entities.Transaction
|
||||||
{
|
{
|
||||||
Amount = package.Price,
|
Amount = gatewayAmount,
|
||||||
Description = $"خرید پکیج {package.Title}",
|
Description = isTwentyCard
|
||||||
|
? $"خرید پکیج {package.Title} (۲۰کارت)"
|
||||||
|
: $"خرید پکیج {package.Title}",
|
||||||
PaymentStatus = Domain.Enums.PaymentStatus.Pending,
|
PaymentStatus = Domain.Enums.PaymentStatus.Pending,
|
||||||
Type = Domain.Enums.TransactionType.Buy
|
Type = Domain.Enums.TransactionType.Buy
|
||||||
};
|
};
|
||||||
_context.Transactions.Add(transaction);
|
_context.Transactions.Add(transaction);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// Create purchase record
|
// Create purchase record — UPP.Amount always Package.Price (catalog)
|
||||||
var purchaseMethod = request.PurchaseMethod == PurchaseMethodEnum.PurchaseMethodGateway
|
var purchaseMethod = request.PurchaseMethod == PurchaseMethodEnum.PurchaseMethodGateway
|
||||||
? Domain.Enums.PackagePurchaseMethod.DirectPurchase
|
? Domain.Enums.PackagePurchaseMethod.DirectPurchase
|
||||||
: Domain.Enums.PackagePurchaseMethod.DayaLoan;
|
: Domain.Enums.PackagePurchaseMethod.DayaLoan;
|
||||||
@@ -204,18 +223,23 @@ public class PackageService : PackageContract.PackageContractBase
|
|||||||
var user = await _context.Users
|
var user = await _context.Users
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(u => u.Id == userId)
|
.Where(u => u.Id == userId)
|
||||||
.Select(u => new { u.Mobile })
|
.Select(u => new { u.Mobile, u.FirstName, u.LastName })
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
// Callback URL از config — نه از ورودی کاربر (امنیت)
|
// Callback URL از config — نه از ورودی کاربر (امنیت)
|
||||||
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268";
|
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268";
|
||||||
var callbackUrl = $"{frontOfficeBaseUrl}/profile/payment-callback?orderId={purchase.Id}";
|
var callbackUrl = $"{frontOfficeBaseUrl}/profile/payment-callback?orderId={purchase.Id}";
|
||||||
|
|
||||||
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
|
var paymentGateway = _paymentGatewayResolver.Get(gatewayProvider);
|
||||||
|
var payerName = $"{user?.FirstName} {user?.LastName}".Trim();
|
||||||
|
|
||||||
|
var paymentResult = await paymentGateway.InitiatePaymentAsync(new PaymentRequest
|
||||||
{
|
{
|
||||||
Amount = package.Price,
|
Amount = gatewayAmount,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Mobile = user?.Mobile ?? string.Empty,
|
Mobile = user?.Mobile ?? string.Empty,
|
||||||
|
Name = payerName,
|
||||||
|
Invoice = purchase.Id.ToString(),
|
||||||
Description = $"خرید پکیج {package.Title}",
|
Description = $"خرید پکیج {package.Title}",
|
||||||
CallbackUrl = callbackUrl
|
CallbackUrl = callbackUrl
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
@@ -235,12 +259,14 @@ public class PackageService : PackageContract.PackageContractBase
|
|||||||
// Save RefId
|
// Save RefId
|
||||||
transaction.RefId = paymentResult.RefId;
|
transaction.RefId = paymentResult.RefId;
|
||||||
|
|
||||||
// ثبت PaymentTransaction
|
// ثبت PaymentTransaction — Amount = gateway amount
|
||||||
var paymentTx = new PaymentTransaction
|
var paymentTx = new PaymentTransaction
|
||||||
{
|
{
|
||||||
GatewayProvider = _configuration["PaymentProvider"] ?? "zarinpal",
|
GatewayProvider = gatewayProvider,
|
||||||
MerchantId = _configuration["ZarinPal:MerchantId"] ?? "",
|
MerchantId = isTwentyCard
|
||||||
Amount = package.Price,
|
? (_configuration["TwentyCard:TerminalId"] ?? "")
|
||||||
|
: (_configuration["ZarinPal:MerchantId"] ?? ""),
|
||||||
|
Amount = gatewayAmount,
|
||||||
CallbackUrl = callbackUrl,
|
CallbackUrl = callbackUrl,
|
||||||
Description = $"خرید پکیج {package.Title}",
|
Description = $"خرید پکیج {package.Title}",
|
||||||
Mobile = user?.Mobile,
|
Mobile = user?.Mobile,
|
||||||
@@ -265,6 +291,14 @@ public class PackageService : PackageContract.PackageContractBase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeGatewayProvider(string? provider)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(provider))
|
||||||
|
return "zarinpal";
|
||||||
|
var key = provider.Trim().ToLowerInvariant();
|
||||||
|
return key is "20card" or "twentycard" ? "20card" : "zarinpal";
|
||||||
|
}
|
||||||
|
|
||||||
public override async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchase(
|
public override async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchase(
|
||||||
CustomerVerifyPackagePurchaseRequest request,
|
CustomerVerifyPackagePurchaseRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
|
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
|
||||||
"UseSandbox": true
|
"UseSandbox": true
|
||||||
},
|
},
|
||||||
|
"TwentyCard": {
|
||||||
|
"BaseUrl": "https://sandbox.20card.ir/ipg",
|
||||||
|
"TerminalId": "",
|
||||||
|
"TerminalUsername": "",
|
||||||
|
"TerminalPassword": "",
|
||||||
|
"CreditPortalUrl": "https://20card.ir",
|
||||||
|
"Packages": {
|
||||||
|
"4": {
|
||||||
|
"GatewayAmountToman": 9700000,
|
||||||
|
"WalletCreditToman": 13000000
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"GatewayAmountToman": 97000000,
|
||||||
|
"WalletCreditToman": 130000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CmsBaseUrl": "https://cms.se.kbs1.ir",
|
"CmsBaseUrl": "https://cms.se.kbs1.ir",
|
||||||
"FrontOfficeBaseUrl": "https://frontoffice.se.kbs1.ir",
|
"FrontOfficeBaseUrl": "https://frontoffice.se.kbs1.ir",
|
||||||
"FMS": {
|
"FMS": {
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
|
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
|
||||||
"UseSandbox": true
|
"UseSandbox": true
|
||||||
},
|
},
|
||||||
|
"TwentyCard": {
|
||||||
|
"BaseUrl": "https://sandbox.20card.ir/ipg",
|
||||||
|
"TerminalId": "",
|
||||||
|
"TerminalUsername": "",
|
||||||
|
"TerminalPassword": "",
|
||||||
|
"CreditPortalUrl": "https://20card.ir",
|
||||||
|
"Packages": {
|
||||||
|
"4": {
|
||||||
|
"GatewayAmountToman": 9700000,
|
||||||
|
"WalletCreditToman": 13000000
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"GatewayAmountToman": 97000000,
|
||||||
|
"WalletCreditToman": 130000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CmsBaseUrl": "https://localhost:32846",
|
"CmsBaseUrl": "https://localhost:32846",
|
||||||
"FrontOfficeBaseUrl": "http://localhost:5268",
|
"FrontOfficeBaseUrl": "http://localhost:5268",
|
||||||
"FMS": {
|
"FMS": {
|
||||||
|
|||||||
Reference in New Issue
Block a user