diff --git a/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs b/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs index babc536..5d432e2 100644 --- a/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs +++ b/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs @@ -20,6 +20,7 @@ public class PaymentGatewayConfig public OpayConfig Opay { get; set; } = new(); public DpoGroupConfig DpoGroup { get; set; } = new(); public PawaPayConfig PawaPay { get; set; } = new(); + public PeachPaymentsConfig PeachPayments { get; set; } = new(); } public class PaystackConfig @@ -156,3 +157,21 @@ public class PawaPayConfig /// public bool IsSandbox { get; set; } = false; } + +public class PeachPaymentsConfig +{ + /// + /// Peach Payments Entity ID (from your Peach Payments dashboard). + /// + public string EntityId { get; set; } = string.Empty; + + /// + /// Peach Payments Access Token (Bearer token). + /// + public string AccessToken { get; set; } = string.Empty; + + /// + /// Set to true to use Peach Payments test/sandbox environment. + /// + public bool IsSandbox { get; set; } = false; +} diff --git a/PayBridge.SDK/Enums/PaymentGatewayType.cs b/PayBridge.SDK/Enums/PaymentGatewayType.cs index a6b2d2e..0628798 100644 --- a/PayBridge.SDK/Enums/PaymentGatewayType.cs +++ b/PayBridge.SDK/Enums/PaymentGatewayType.cs @@ -77,5 +77,10 @@ public enum PaymentGatewayType /// /// PawaPay payment gateway (Africa - mobile money) /// - PawaPay = 14 + PawaPay = 14, + + /// + /// Peach Payments gateway (South Africa, Kenya, Nigeria) + /// + PeachPayments = 15 } \ No newline at end of file diff --git a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs index da37a56..171e771 100644 --- a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs +++ b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs @@ -139,6 +139,7 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return; } @@ -190,6 +191,9 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway case PaymentGatewayType.PawaPay: services.AddScoped(); break; + case PaymentGatewayType.PeachPayments: + services.AddScoped(); + break; } } } diff --git a/PayBridge.SDK/Factories/PaymentGatewayFactory.cs b/PayBridge.SDK/Factories/PaymentGatewayFactory.cs index f99e477..0a0be6a 100644 --- a/PayBridge.SDK/Factories/PaymentGatewayFactory.cs +++ b/PayBridge.SDK/Factories/PaymentGatewayFactory.cs @@ -49,6 +49,7 @@ public Dictionary CreateGateways() TryAddGateway(gateways, PaymentGatewayType.Opay); TryAddGateway(gateways, PaymentGatewayType.DpoGroup); TryAddGateway(gateways, PaymentGatewayType.PawaPay); + TryAddGateway(gateways, PaymentGatewayType.PeachPayments); } else { @@ -96,6 +97,7 @@ private void TryAddGateway(Dictionary gatew PaymentGatewayType.Opay => _serviceProvider.GetService(), PaymentGatewayType.DpoGroup => _serviceProvider.GetService(), PaymentGatewayType.PawaPay => _serviceProvider.GetService(), + PaymentGatewayType.PeachPayments => _serviceProvider.GetService(), _ => null }; diff --git a/PayBridge.SDK/Gateways/PeachPaymentsGateway.cs b/PayBridge.SDK/Gateways/PeachPaymentsGateway.cs new file mode 100644 index 0000000..5c0f8de --- /dev/null +++ b/PayBridge.SDK/Gateways/PeachPaymentsGateway.cs @@ -0,0 +1,240 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using PayBridge.SDK.Application.Dtos; +using PayBridge.SDK.Application.Dtos.Request; +using PayBridge.SDK.Dtos.Request; +using PayBridge.SDK.Dtos.Response; +using PayBridge.SDK.Enums; +using PayBridge.SDK.Exceptions; +using PayBridge.SDK.Interfaces; + +namespace PayBridge.SDK; + +/// +/// Peach Payments gateway (South Africa, Kenya, Nigeria, Botswana). +/// Currencies: ZAR, KES, NGN, BWP, USD. +/// Auth: Bearer AccessToken + EntityId. +/// Docs: https://developer.peachpayments.com +/// +public class PeachPaymentsGateway : IPaymentGateway +{ + private const string LiveBaseUrl = "https://eu-prod.oppwa.com/v1"; + private const string SandboxBaseUrl = "https://eu-test.oppwa.com/v1"; + private const string TxRefPrefix = "PEACH_"; + + private readonly PaymentGatewayConfig _config; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public PeachPaymentsGateway( + PaymentGatewayConfig config, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _httpClient = httpClientFactory.CreateClient(nameof(PeachPaymentsGateway)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public PaymentGatewayType GatewayType => PaymentGatewayType.PeachPayments; + + private string BaseUrl => _config.PeachPayments.IsSandbox ? SandboxBaseUrl : LiveBaseUrl; + + private void SetAuthHeader() + { + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", _config.PeachPayments.AccessToken); + } + + public async Task CreatePaymentAsync(PaymentRequest request) + { + try + { + var txRef = $"{TxRefPrefix}{Guid.NewGuid():N}"; + + var formData = new Dictionary + { + ["entityId"] = _config.PeachPayments.EntityId, + ["amount"] = request.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + ["currency"] = request.Currency ?? "ZAR", + ["paymentType"] = "DB", + ["merchantTransactionId"] = txRef, + ["customer.email"] = request.CustomerEmail ?? string.Empty, + ["customer.givenName"] = request.CustomerName?.Split(' ').FirstOrDefault() ?? string.Empty, + ["customer.surname"] = request.CustomerName?.Split(' ').LastOrDefault() ?? string.Empty, + ["billing.country"] = CurrencyToCountry(request.Currency ?? "ZAR"), + }; + + SetAuthHeader(); + var content = new FormUrlEncodedContent(formData); + var response = await _httpClient.PostAsync($"{BaseUrl}/checkouts", content); + var body = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("PeachPayments checkout response: {Body}", body); + + if (!response.IsSuccessStatusCode) + { + return new PaymentResponse + { + Success = false, + Message = $"PeachPayments checkout failed: {response.StatusCode}", + Status = PaymentStatus.Failed + }; + } + + var json = JsonDocument.Parse(body).RootElement; + var resultCode = json.TryGetProperty("result", out var resultEl) && + resultEl.TryGetProperty("code", out var codeEl) + ? codeEl.GetString() : null; + + var checkoutId = json.TryGetProperty("id", out var idEl) ? idEl.GetString() : null; + bool success = resultCode != null && resultCode.StartsWith("000"); + + var paymentUrl = checkoutId != null + ? $"{BaseUrl}/paymentWidgets.js?checkoutId={checkoutId}" + : null; + + return new PaymentResponse + { + Success = success, + TransactionReference = txRef, + CheckoutUrl = paymentUrl ?? string.Empty, + Message = resultEl.TryGetProperty("description", out var descEl) + ? descEl.GetString() ?? "Unknown" : "Unknown", + Status = success ? PaymentStatus.Pending : PaymentStatus.Failed, + GatewayResponse = new Dictionary + { + ["checkoutId"] = checkoutId ?? string.Empty, + ["resultCode"] = resultCode ?? string.Empty + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initiating PeachPayments checkout"); + throw new PaymentGatewayException("Failed to initiate PeachPayments payment", ex); + } + } + + public async Task VerifyPaymentAsync(string transactionReference) + { + try + { + var url = $"{BaseUrl}/payments?entityId={Uri.EscapeDataString(_config.PeachPayments.EntityId)}" + + $"&merchantTransactionId={Uri.EscapeDataString(transactionReference)}"; + + SetAuthHeader(); + var response = await _httpClient.GetAsync(url); + var body = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("PeachPayments verify response: {Body}", body); + + if (!response.IsSuccessStatusCode) + { + return new VerificationResponse + { + Success = false, + TransactionReference = transactionReference, + Status = PaymentStatus.Failed, + Message = $"PeachPayments verification failed: {response.StatusCode}" + }; + } + + var json = JsonDocument.Parse(body).RootElement; + var payment = json.ValueKind == JsonValueKind.Array ? json[0] : json; + + var resultCode = payment.TryGetProperty("result", out var resultEl) && + resultEl.TryGetProperty("code", out var codeEl) + ? codeEl.GetString() : null; + + bool success = resultCode != null && + (resultCode.StartsWith("000.0") || resultCode.StartsWith("000.100")); + + decimal.TryParse( + payment.TryGetProperty("amount", out var amtEl) ? amtEl.GetString() : "0", + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, + out var amount); + + var currency = payment.TryGetProperty("currency", out var curEl) + ? curEl.GetString() ?? string.Empty : string.Empty; + + var paymentBrand = payment.TryGetProperty("paymentBrand", out var pbEl) + ? pbEl.GetString() ?? "Card" : "Card"; + + return new VerificationResponse + { + Success = success, + TransactionReference = transactionReference, + Amount = amount, + Currency = currency, + Status = success ? PaymentStatus.Successful : PaymentStatus.Failed, + Message = resultEl.TryGetProperty("description", out var descEl) + ? descEl.GetString() ?? "Unknown" : "Unknown", + PaymentMethod = paymentBrand, + GatewayResponse = new Dictionary + { + ["resultCode"] = resultCode ?? string.Empty + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error verifying PeachPayments payment: {Ref}", transactionReference); + throw new PaymentGatewayException("Failed to verify PeachPayments payment", ex); + } + } + + public async Task RefundPaymentAsync(RefundRequest request) + { + try + { + var formData = new Dictionary + { + ["entityId"] = _config.PeachPayments.EntityId, + ["amount"] = request.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + ["currency"] = "ZAR", + ["paymentType"] = "RF", + }; + + SetAuthHeader(); + var content = new FormUrlEncodedContent(formData); + var response = await _httpClient.PostAsync($"{BaseUrl}/payments/{request.TransactionReference}", content); + var body = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("PeachPayments refund response: {Body}", body); + + var json = JsonDocument.Parse(body).RootElement; + var resultCode = json.TryGetProperty("result", out var resultEl) && + resultEl.TryGetProperty("code", out var codeEl) + ? codeEl.GetString() : null; + + bool success = resultCode != null && resultCode.StartsWith("000"); + var refundId = json.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? string.Empty : string.Empty; + + return new RefundResponse + { + Success = success, + RefundReference = refundId, + TransactionReference = request.TransactionReference, + Amount = request.Amount, + Status = success ? PaymentStatus.Refunded : PaymentStatus.Failed, + Message = resultEl.TryGetProperty("description", out var descEl) + ? descEl.GetString() ?? "Unknown" : "Unknown" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing PeachPayments refund: {Ref}", request.TransactionReference); + throw new PaymentGatewayException("Failed to process PeachPayments refund", ex); + } + } + + private static string CurrencyToCountry(string currency) => currency.ToUpperInvariant() switch + { + "ZAR" => "ZA", + "KES" => "KE", + "NGN" => "NG", + "BWP" => "BW", + _ => "ZA" + }; +} diff --git a/PayBridge.SDK/Services/PaymentService.cs b/PayBridge.SDK/Services/PaymentService.cs index 080c32f..46fc3ed 100644 --- a/PayBridge.SDK/Services/PaymentService.cs +++ b/PayBridge.SDK/Services/PaymentService.cs @@ -262,7 +262,10 @@ private PaymentGatewayType SelectBestGateway(PaymentRequest request) case "XOF": case "XAF": case "MWK": - return ChooseAvailableGateway(PaymentGatewayType.PawaPay, PaymentGatewayType.DpoGroup, PaymentGatewayType.Flutterwave, PaymentGatewayType.Paystack); + return ChooseAvailableGateway(PaymentGatewayType.PeachPayments, PaymentGatewayType.PawaPay, PaymentGatewayType.DpoGroup, PaymentGatewayType.Flutterwave, PaymentGatewayType.Paystack); + + case "BWP": + return ChooseAvailableGateway(PaymentGatewayType.PeachPayments, PaymentGatewayType.DpoGroup); case "BHD": return ChooseAvailableGateway(PaymentGatewayType.BenefitPay); @@ -367,6 +370,11 @@ private PaymentGatewayType DetermineGatewayFromReference(string transactionRefer { return PaymentGatewayType.PawaPay; } + + if (transactionReference.StartsWith("PEACH_")) + { + return PaymentGatewayType.PeachPayments; + } // Default to configured default gateway _logger.LogWarning("Could not determine gateway from reference: {Reference}", transactionReference); return _config.DefaultGateway;