using LiteCharms.Features.Abstractions; using LiteCharms.Features.Hasher; using LiteCharms.Features.MidrandBooks.Payments.Models; using LiteCharms.Features.MidrandBooks.Postgres; namespace LiteCharms.Features.MidrandBooks.Payments; public sealed partial class PayfastService(IDbContextFactory contextFactory, ILogger logger, IHttpClientFactory httpClientFactory, IConfiguration configuration) : IService { [GeneratedRegex(@"%[0-9A-Fa-f]{2}", RegexOptions.None, matchTimeoutMilliseconds: 1000)] public static partial Regex PercentEncodingRegex { get; } public readonly string[] ValidHosts = configuration.GetSection("ValidPayfastHosts").Get() ?? []; public async ValueTask> WriteLedgerEntryAsync(CreateGatewayLedgerEntry request, CancellationToken cancellationToken = default) { try { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); if(!await context.Orders.AnyAsync(o => o.Id == request.OrderId, cancellationToken)) return Result.Fail("Referenced order ID does not exist in database."); if(!await context.Payments.AnyAsync(p => p.Id == request.PaymentId, cancellationToken)) return Result.Fail("Referenced payment ID does not exist in database."); var entry = context.GatewayLedger.Add(new Entities.PaymentGatewayLedger { CustomerEmail = request.CustomerEmail, OrderId = request.OrderId, PaymentId = request.PaymentId, MerchantPaymentId = request.MerchantPaymentId, PayfastPaymentId = request.PayfastPaymentId, PaymentStatus = request.PaymentStatus, AmountGross = request.AmountGross, AmountFee = request.AmountFee, AmountNet = request.AmountNet, CreatedAt = DateTime.UtcNow, }); return await context.SaveChangesAsync(cancellationToken) > 0 ? Result.Ok(entry.Entity.Id) : Result.Fail("Failed to save Payfast ledger entry to database."); } catch (Exception ex) { return Result.Fail(new Error("Failed to write Payfast ledger entry to database.").CausedBy(ex)); } } public async ValueTask> ValidateReferrerIpAsync(string remoteIpAddress, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(remoteIpAddress)) return Result.Fail("Remote IP address is null or whitespace."); try { var validIps = new HashSet(); foreach (var host in ValidHosts) { try { var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); foreach (var addr in addresses) validIps.Add(addr); } catch (SocketException ex) { logger.LogWarning(ex, "DNS warning: Failed to resolve Payfast node '{Host}'. It may be decommissioned or unreachable.", host); } } if (IPAddress.TryParse(remoteIpAddress, out var incomingIp)) { bool isValid = validIps.Contains(incomingIp); if (!isValid) logger.LogWarning("SECURITY ALERT: Webhook IP '{RemoteIp}' originated from an unlisted host schema.", remoteIpAddress); return Result.Ok(isValid); } return Result.Fail("Invalid remote IP address format."); } catch (Exception ex) { return Result.Fail(new Error("DNS Verification error while scanning Payfast IP nodes.").CausedBy(ex)); } } public Result ValidatePaymentAmount(decimal expectedTotal, string? amountGrossString) { if (!decimal.TryParse(amountGrossString, CultureInfo.InvariantCulture, out decimal grossAmount)) return Result.Fail("Failed to parse payment amount."); decimal delta = Math.Abs(expectedTotal - grossAmount); bool isAmountValid = delta <= 0.01m; if (!isAmountValid) logger.LogError("FINANCIAL DRIFT EXCEPTION: Expected order total R{Expected} but gateway cleared R{Cleared}.", expectedTotal, grossAmount); return Result.Ok(isAmountValid); } public async ValueTask> ValidateServerConfirmationAsync(string rawQueryParamString, bool isSandbox, CancellationToken ct) { try { string host = isSandbox ? "sandbox.payfast.co.za" : "www.payfast.co.za"; string targetUrl = $"https://{host}/eng/query/validate"; using var content = new StringContent(rawQueryParamString, Encoding.UTF8, "application/x-www-form-urlencoded"); var httpClient = httpClientFactory.CreateClient(); var response = await httpClient.PostAsync(targetUrl, content, ct); if (!response.IsSuccessStatusCode) return Result.Fail("Failed to validate server confirmation."); string responseText = await response.Content.ReadAsStringAsync(ct); bool isValidated = string.Equals(responseText.Trim(), "VALID", StringComparison.OrdinalIgnoreCase); if (!isValidated) logger.LogWarning("SECURITY WARNING: Payfast back-channel returned validation response: '{Response}'", responseText); return Result.Ok(isValidated); } catch (Exception ex) { return Result.Fail(new Error("Failed to complete back-channel cURL verification handshakes with Payfast remote endpoints.").CausedBy(ex)); } } public static Result GenerateSignature(IDictionary data, string? passPhrase = null) { var pfOutput = new StringBuilder(); foreach (var kvp in data) { if (string.IsNullOrEmpty(kvp.Value)) continue; string key = kvp.Key; string encodedVal = HttpUtility.UrlEncode(kvp.Value.Trim()); string val = PercentEncodingRegex.Replace(encodedVal, m => m.Value.ToLowerInvariant()); pfOutput.Append($"{key}={val}&"); } string getString = pfOutput.Length > 0 ? pfOutput.ToString()[..^1] : string.Empty; if (!string.IsNullOrWhiteSpace(passPhrase)) { string encodedPassphrase = HttpUtility.UrlEncode(passPhrase.Trim()); string safePassphrase = PercentEncodingRegex.Replace(encodedPassphrase, m => m.Value.ToLowerInvariant()); getString += $"&passphrase={safePassphrase}"; } return HashService.ToMd5Hash(getString); } }