179 lines
8.6 KiB
C#
179 lines
8.6 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Fitness_core_BE.DTOs.PaymentGateways;
|
|
using Fitness_core_BE.Exceptions;
|
|
using Fitness_core_BE.Infra.UoW;
|
|
using Fitness_core_BE.Interfaces;
|
|
using Fitness_core_BE.Interfaces.Services;
|
|
|
|
namespace Fitness_core_BE.Services
|
|
{
|
|
public class PaymentGatewayClient : IPaymentGatewayClient
|
|
{
|
|
private readonly IClientRepository _clientRepository;
|
|
private readonly IPaymentServiceClient _paymentServiceClient;
|
|
private readonly IUnitOfWork _uow;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly string _baseUrl;
|
|
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
|
|
|
public PaymentGatewayClient(
|
|
IClientRepository clientRepository,
|
|
IPaymentServiceClient paymentServiceClient,
|
|
IUnitOfWork uow,
|
|
IHttpClientFactory httpClientFactory,
|
|
IConfiguration configuration)
|
|
{
|
|
_clientRepository = clientRepository;
|
|
_paymentServiceClient = paymentServiceClient;
|
|
_uow = uow;
|
|
_httpClient = httpClientFactory.CreateClient();
|
|
_baseUrl = (configuration["PaymentService:BaseUrl"] ?? "http://127.0.0.1:5120").TrimEnd('/');
|
|
}
|
|
|
|
public async Task<OnboardCardResult> OnboardCardAsync(Guid clientId, OnboardCardRequest request, CancellationToken ct = default)
|
|
{
|
|
var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/cards/onboard", request, ct);
|
|
return Deserialize<OnboardCardResult>(body);
|
|
}
|
|
|
|
public async Task<CardDetailsResult> GetCardDetailsAsync(Guid clientId, string cardToken, CancellationToken ct = default)
|
|
{
|
|
var body = await SendAsync(clientId, HttpMethod.Get, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}", null, ct);
|
|
return Deserialize<CardDetailsResult>(body);
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<CardDetailsResult>> ListCardsAsync(Guid clientId, string customerId, CancellationToken ct = default)
|
|
{
|
|
var body = await SendAsync(clientId, HttpMethod.Get, "/api/v1/cards", null, ct,
|
|
query: $"customerId={Uri.EscapeDataString(customerId)}");
|
|
return Deserialize<List<CardDetailsResult>>(body);
|
|
}
|
|
|
|
public async Task DeactivateCardAsync(Guid clientId, string cardToken, CancellationToken ct = default)
|
|
{
|
|
await SendAsync(clientId, HttpMethod.Delete, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}", null, ct);
|
|
}
|
|
|
|
public async Task<SetDefaultCardResult> SetDefaultCardAsync(Guid clientId, string cardToken, string customerId, CancellationToken ct = default)
|
|
{
|
|
var body = await SendAsync(clientId, HttpMethod.Patch, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}/default",
|
|
new { customerId }, ct);
|
|
return Deserialize<SetDefaultCardResult>(body);
|
|
}
|
|
|
|
public async Task<OnboardCardResult> GetOnboardingStatusAsync(Guid clientId, string gatewayName, string sessionId, CancellationToken ct = default)
|
|
{
|
|
var body = await SendAsync(clientId, HttpMethod.Get,
|
|
$"/api/v1/cards/onboarding/{Uri.EscapeDataString(gatewayName)}/{Uri.EscapeDataString(sessionId)}", null, ct);
|
|
return Deserialize<OnboardCardResult>(body);
|
|
}
|
|
|
|
public async Task<AttemptPaymentResult> AttemptPaymentAsync(Guid clientId, AttemptPaymentRequest request, CancellationToken ct = default)
|
|
{
|
|
var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/payments/attempt", request, ct);
|
|
return Deserialize<AttemptPaymentResult>(body);
|
|
}
|
|
|
|
public async Task<RefundPaymentResult> RefundPaymentAsync(Guid clientId, Guid paymentId, bool isVoid, CancellationToken ct = default)
|
|
{
|
|
var body = await SendAsync(clientId, HttpMethod.Post, $"/api/v1/refunds/{paymentId}", new { isVoid }, ct);
|
|
return Deserialize<RefundPaymentResult>(body);
|
|
}
|
|
|
|
public async Task<PaymentListResult> ListPaymentsAsync(Guid clientId, string? cardToken, int page, int pageSize, CancellationToken ct = default)
|
|
{
|
|
var queryParts = new List<string> { $"page={page}", $"pageSize={pageSize}" };
|
|
if (!string.IsNullOrWhiteSpace(cardToken))
|
|
{
|
|
queryParts.Add($"cardToken={Uri.EscapeDataString(cardToken)}");
|
|
}
|
|
|
|
var body = await SendAsync(clientId, HttpMethod.Get, "/api/v1/payments", null, ct, query: string.Join("&", queryParts));
|
|
return Deserialize<PaymentListResult>(body);
|
|
}
|
|
|
|
private static T Deserialize<T>(string body)
|
|
{
|
|
return JsonSerializer.Deserialize<T>(body, JsonOptions)
|
|
?? throw new ServiceUnavailableException("Payment-Service returned an empty response.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mints this client's Payment-Service Platform the first time it's needed, storing the
|
|
/// ApiKey/HmacSecret on Client since Payment-Service only ever returns them once.
|
|
/// </summary>
|
|
private async Task<(string ApiKey, string HmacSecret)> EnsureCredentialsAsync(Guid clientId, CancellationToken ct)
|
|
{
|
|
var client = await _clientRepository.GetClientEntityByIdAsync(clientId, ct)
|
|
?? throw new NotFoundException($"Client {clientId} not found.");
|
|
|
|
if (string.IsNullOrEmpty(client.ClientPaymentApiKey) || string.IsNullOrEmpty(client.ClientPaymentHmacSecret))
|
|
{
|
|
var (platformId, apiKey, hmacSecret) = await _paymentServiceClient.ProvisionPlatformAsync(
|
|
clientId, client.ClientName ?? "Gym", ct);
|
|
|
|
client.FKPaymentPlatformId = platformId;
|
|
client.ClientPaymentApiKey = apiKey;
|
|
client.ClientPaymentHmacSecret = hmacSecret;
|
|
await _uow.SaveChangesAsync(ct);
|
|
}
|
|
|
|
return (client.ClientPaymentApiKey!, client.ClientPaymentHmacSecret!);
|
|
}
|
|
|
|
private async Task<string> SendAsync(Guid clientId, HttpMethod method, string path, object? body, CancellationToken ct, string? query = null)
|
|
{
|
|
var (apiKey, hmacSecret) = await EnsureCredentialsAsync(clientId, ct);
|
|
|
|
var bodyJson = body is null ? string.Empty : JsonSerializer.Serialize(body);
|
|
var timestamp = DateTimeOffset.UtcNow.ToString("o");
|
|
// Signed over the path only — Payment-Service's HmacValidationMiddleware signs
|
|
// against Request.Path.Value, which never includes the query string.
|
|
var signature = ComputeSignature(hmacSecret, method.Method, path, timestamp, bodyJson);
|
|
|
|
var url = string.IsNullOrEmpty(query) ? $"{_baseUrl}{path}" : $"{_baseUrl}{path}?{query}";
|
|
using var httpRequest = new HttpRequestMessage(method, url);
|
|
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
httpRequest.Headers.Add("X-Api-Key", apiKey);
|
|
httpRequest.Headers.Add("X-Timestamp", timestamp);
|
|
httpRequest.Headers.Add("X-Signature", signature);
|
|
|
|
if (body is not null)
|
|
{
|
|
httpRequest.Content = new StringContent(bodyJson, Encoding.UTF8, "application/json");
|
|
}
|
|
|
|
HttpResponseMessage response;
|
|
try
|
|
{
|
|
response = await _httpClient.SendAsync(httpRequest, ct);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
throw new ServiceUnavailableException("Could not reach Payment-Service.", ex);
|
|
}
|
|
|
|
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new ServiceUnavailableException($"Payment-Service call failed ({(int)response.StatusCode}): {responseBody}");
|
|
}
|
|
|
|
return responseBody;
|
|
}
|
|
|
|
// Must match Payment-Service's Middleware/HmacValidationMiddleware.ComputeSignature
|
|
// exactly: METHOD\nPATH\nTIMESTAMP\nBODY, HMACSHA256, hex lowercase.
|
|
private static string ComputeSignature(string secret, string method, string path, string timestamp, string body)
|
|
{
|
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
|
var payload = $"{method}\n{path}\n{timestamp}\n{body}";
|
|
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
}
|
|
}
|