217 lines
8.6 KiB
C#
217 lines
8.6 KiB
C#
using LiteCharms.Features.Abstractions;
|
|
using LiteCharms.Features.Api.Configuration;
|
|
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<MidrandBooksDbContext> contextFactory,
|
|
IOptions<PayfastSettings> payfastOptions, ILogger<PayfastService> logger, IHttpClientFactory httpClientFactory) : IService
|
|
{
|
|
[GeneratedRegex(@"%[0-9A-Fa-f]{2}", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
|
|
public static partial Regex PercentEncodingRegex { get; }
|
|
|
|
public async ValueTask<Result<long>> 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<long>("Referenced order ID does not exist in database.");
|
|
|
|
if(!await context.Payments.AnyAsync(p => p.Id == request.PaymentId, cancellationToken))
|
|
return Result.Fail<long>("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<long>("Failed to save Payfast ledger entry to database.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Result.Fail<long>(new Error("Failed to write Payfast ledger entry to database.").CausedBy(ex));
|
|
}
|
|
}
|
|
|
|
public async ValueTask<Result<bool>> ValidateReferrerIpAsync(string remoteIpAddress, bool allowLoopback = false, CancellationToken cancellationToken = default)
|
|
{
|
|
if(payfastOptions.Value?.ValidHosts?.Length == 0)
|
|
return Result.Fail<bool>("Valid payfast hosts not configured.");
|
|
|
|
if (string.IsNullOrWhiteSpace(remoteIpAddress))
|
|
return Result.Fail<bool>("Remote IP address is null or whitespace.");
|
|
|
|
try
|
|
{
|
|
var validIps = new HashSet<IPAddress>();
|
|
|
|
foreach (var host in payfastOptions.Value!.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))
|
|
{
|
|
if (allowLoopback && IPAddress.IsLoopback(incomingIp))
|
|
{
|
|
logger.LogInformation("Local development loopback IP '{RemoteIp}' allowed bypassing DNS verification.", remoteIpAddress);
|
|
return Result.Ok(true);
|
|
}
|
|
|
|
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<bool>("Invalid remote IP address format.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Result.Fail<bool>(new Error("DNS Verification error while scanning Payfast IP nodes.").CausedBy(ex));
|
|
}
|
|
}
|
|
|
|
public Result<bool> ValidatePaymentAmount(decimal expectedTotal, string? amountGrossString)
|
|
{
|
|
if (!decimal.TryParse(amountGrossString, CultureInfo.InvariantCulture, out decimal grossAmount))
|
|
return Result.Fail<bool>("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<Result<bool>> 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<bool>("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<bool>(new Error("Failed to complete back-channel cURL verification handshakes with Payfast remote endpoints.").CausedBy(ex));
|
|
}
|
|
}
|
|
|
|
public static Result<string> GenerateSignature(IDictionary<string, string?> data, string? passPhrase = null)
|
|
{
|
|
var pfOutput = new StringBuilder();
|
|
|
|
// Define the exact structural sequence mandated by Payfast's documentation
|
|
string[] mandatorySequence =
|
|
[
|
|
"merchant_id",
|
|
"merchant_key",
|
|
"return_url",
|
|
"cancel_url",
|
|
"notify_url",
|
|
"name_first",
|
|
"name_last",
|
|
"email_address",
|
|
"cell_number",
|
|
"m_payment_id",
|
|
"amount",
|
|
"item_name",
|
|
"item_description",
|
|
"custom_int1",
|
|
"custom_int2",
|
|
"custom_int3",
|
|
"custom_int4",
|
|
"custom_int5",
|
|
"custom_str1",
|
|
"custom_str2",
|
|
"custom_str3",
|
|
"custom_str4",
|
|
"custom_str5",
|
|
"email_confirmation",
|
|
"confirmation_address",
|
|
"payment_method",
|
|
"subscription_type",
|
|
"billing_date",
|
|
"recurring_amount",
|
|
"frequency",
|
|
"cycles"
|
|
];
|
|
|
|
// 1. Iterate explicitly by the mandatory positional array sequence instead of the dictionary's internal order
|
|
foreach (string key in mandatorySequence)
|
|
{
|
|
// Only append if the key exists in your source dictionary and contains data
|
|
if (data.TryGetValue(key, out string? rawValue) && !string.IsNullOrEmpty(rawValue))
|
|
{
|
|
// Payfast requires spaces to be '+' signs. HttpUtility does this natively.
|
|
string encodedVal = HttpUtility.UrlEncode(rawValue.Trim());
|
|
|
|
// Payfast requires all OTHER percent-encoded hex arrays to be UPPERCASE (e.g., %3A instead of %3a)
|
|
string val = Regex.Replace(encodedVal, "%[0-9A-Fa-f]{2}", m => m.Value.ToUpperInvariant());
|
|
|
|
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 = Regex.Replace(encodedPassphrase, "%[0-9A-Fa-f]{2}", m => m.Value.ToUpperInvariant());
|
|
|
|
getString += $"&passphrase={safePassphrase}";
|
|
}
|
|
|
|
return HashService.ToMd5Hash(getString);
|
|
}
|
|
}
|