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 OnboardCardAsync(Guid clientId, OnboardCardRequest request, CancellationToken ct = default) { var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/cards/onboard", request, ct); return Deserialize(body); } public async Task 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(body); } public async Task> 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>(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 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(body); } public async Task 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(body); } public async Task AttemptPaymentAsync(Guid clientId, AttemptPaymentRequest request, CancellationToken ct = default) { var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/payments/attempt", request, ct); return Deserialize(body); } public async Task 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(body); } public async Task ListPaymentsAsync(Guid clientId, string? cardToken, int page, int pageSize, CancellationToken ct = default) { var queryParts = new List { $"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(body); } private static T Deserialize(string body) { return JsonSerializer.Deserialize(body, JsonOptions) ?? throw new ServiceUnavailableException("Payment-Service returned an empty response."); } /// /// 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. /// 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 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(); } } }