diff --git a/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs b/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs index dae6b64..627884e 100644 --- a/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs +++ b/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs @@ -17,6 +17,7 @@ public class PaymentGatewayConfig public KorapayConfig Korapay { get; set; } = new(); public InterswitchConfig Interswitch { get; set; } = new(); public RemitaConfig Remita { get; set; } = new(); + public OpayConfig Opay { get; set; } = new(); } public class PaystackConfig @@ -112,5 +113,13 @@ public class RemitaConfig public string MerchantId { get; set; } = string.Empty; public string ServiceTypeId { get; set; } = string.Empty; public string ApiKey { get; set; } = string.Empty; - public bool IsSandbox { get; set; } = true; + public bool IsSandbox { get; set; } = false; +} + +public class OpayConfig +{ + public string MerchantId { get; set; } = string.Empty; + public string PublicKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + public bool IsSandbox { get; set; } = false; } diff --git a/PayBridge.SDK/Enums/PaymentGatewayType.cs b/PayBridge.SDK/Enums/PaymentGatewayType.cs index 6de06d3..71f0264 100644 --- a/PayBridge.SDK/Enums/PaymentGatewayType.cs +++ b/PayBridge.SDK/Enums/PaymentGatewayType.cs @@ -62,5 +62,10 @@ public enum PaymentGatewayType /// /// Remita payment gateway (Nigeria) /// - Remita = 11 + Remita = 11, + + /// + /// OPay payment gateway (Nigeria / Africa) + /// + Opay = 12 } \ No newline at end of file diff --git a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs index 57382d1..8878660 100644 --- a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs +++ b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using PayBridge.SDK.Application.Dtos; using PayBridge.SDK.Enums; -using PayBridge.SDK.Gateways; using PayBridge.SDK.Interfaces; namespace PayBridge.SDK; @@ -137,6 +136,7 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return; } @@ -179,6 +179,9 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway case PaymentGatewayType.Remita: services.AddScoped(); break; + case PaymentGatewayType.Opay: + services.AddScoped(); + break; } } } diff --git a/PayBridge.SDK/Factories/PaymentGatewayFactory.cs b/PayBridge.SDK/Factories/PaymentGatewayFactory.cs index e848118..eac0466 100644 --- a/PayBridge.SDK/Factories/PaymentGatewayFactory.cs +++ b/PayBridge.SDK/Factories/PaymentGatewayFactory.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Logging; using PayBridge.SDK.Application.Dtos; using PayBridge.SDK.Enums; -using PayBridge.SDK.Gateways; using PayBridge.SDK.Interfaces; namespace PayBridge.SDK; @@ -47,6 +46,7 @@ public Dictionary CreateGateways() TryAddGateway(gateways, PaymentGatewayType.Korapay); TryAddGateway(gateways, PaymentGatewayType.Interswitch); TryAddGateway(gateways, PaymentGatewayType.Remita); + TryAddGateway(gateways, PaymentGatewayType.Opay); } else { @@ -91,6 +91,7 @@ private void TryAddGateway(Dictionary gatew PaymentGatewayType.Korapay => _serviceProvider.GetService(), PaymentGatewayType.Interswitch => _serviceProvider.GetService(), PaymentGatewayType.Remita => _serviceProvider.GetService(), + PaymentGatewayType.Opay => _serviceProvider.GetService(), _ => null }; diff --git a/PayBridge.SDK/Gateways/OpayGateway.cs b/PayBridge.SDK/Gateways/OpayGateway.cs new file mode 100644 index 0000000..145e633 --- /dev/null +++ b/PayBridge.SDK/Gateways/OpayGateway.cs @@ -0,0 +1,246 @@ +using System.Security.Cryptography; +using System.Text; +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; + +/// +/// OPay payment gateway implementation (Nigeria / Africa). +/// Supports card, bank transfer, USSD, and mobile money payments. +/// +public class OpayGateway : IPaymentGateway +{ + private readonly PaymentGatewayConfig _config; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private const string LiveBaseUrl = "https://cashierapi.opayweb.com/api/v3"; + private const string SandboxBaseUrl = "https://sandboxapi.opaycheckout.com/api/v3"; + private const string TxRefPrefix = "OP_"; + + public OpayGateway( + PaymentGatewayConfig config, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _httpClient = httpClientFactory.CreateClient(nameof(OpayGateway)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public PaymentGatewayType GatewayType => PaymentGatewayType.Opay; + + private string BaseUrl => _config.Opay.IsSandbox ? SandboxBaseUrl : LiveBaseUrl; + + /// + /// Generates HMAC-SHA512 signature: HMAC512(jsonBody, secretKey) + /// + private string GenerateSignature(string jsonBody) + { + var keyBytes = Encoding.UTF8.GetBytes(_config.Opay.SecretKey); + var msgBytes = Encoding.UTF8.GetBytes(jsonBody); + var hash = HMACSHA512.HashData(keyBytes, msgBytes); + return Convert.ToHexString(hash).ToLower(); + } + + public async Task CreatePaymentAsync(PaymentRequest request) + { + try + { + var txRef = $"{TxRefPrefix}{Guid.NewGuid():N}"; + var amountKobo = (long)(request.Amount * 100); + + var payload = new + { + merchantId = _config.Opay.MerchantId, + reference = txRef, + amount = new { total = amountKobo, currency = request.Currency ?? "NGN" }, + returnUrl = request.RedirectUrl ?? string.Empty, + callbackUrl = request.WebhookUrl ?? string.Empty, + cancelUrl = request.RedirectUrl ?? string.Empty, + expireAt = 30, + userInfo = new + { + userEmail = request.CustomerEmail, + userName = request.CustomerName ?? string.Empty, + userMobile = request.CustomerPhone ?? string.Empty + }, + product = new + { + name = request.Description ?? "Payment", + description = request.Description ?? "Payment" + } + }; + + var json = JsonSerializer.Serialize(payload); + var signature = GenerateSignature(json); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {_config.Opay.PublicKey}"); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("MerchantId", _config.Opay.MerchantId); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Signature", signature); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json"); + + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync($"{BaseUrl}/international/cashier/create", content); + + var responseBody = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("OPay create response: {Body}", responseBody); + + var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + + var code = root.TryGetProperty("code", out var c) ? c.GetString() : null; + + if (code != "00000") + { + var msg = root.TryGetProperty("message", out var m) ? m.GetString() : "Unknown error"; + return new PaymentResponse + { + Success = false, + Message = $"OPay payment initiation failed: {msg}", + Status = PaymentStatus.Failed + }; + } + + var data = root.GetProperty("data"); + var cashierUrl = data.TryGetProperty("cashierUrl", out var cu) ? cu.GetString() : string.Empty; + + return new PaymentResponse + { + Success = true, + TransactionReference = txRef, + CheckoutUrl = cashierUrl ?? string.Empty, + Message = "Payment initiated successfully", + Status = PaymentStatus.Pending, + GatewayResponse = new Dictionary + { + ["reference"] = txRef, + ["cashierUrl"] = cashierUrl ?? string.Empty + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initiating OPay payment"); + throw new PaymentGatewayException("Failed to initiate OPay payment", ex); + } + } + + public async Task VerifyPaymentAsync(string transactionReference) + { + try + { + var payload = new + { + merchantId = _config.Opay.MerchantId, + reference = transactionReference + }; + + var json = JsonSerializer.Serialize(payload); + var signature = GenerateSignature(json); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {_config.Opay.PublicKey}"); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("MerchantId", _config.Opay.MerchantId); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Signature", signature); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json"); + + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync($"{BaseUrl}/international/cashier/query", content); + + var responseBody = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("OPay verify response: {Body}", responseBody); + + var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + + var code = root.TryGetProperty("code", out var c) ? c.GetString() : null; + var data = code == "00000" && root.TryGetProperty("data", out var d) ? d : (JsonElement?)null; + + // OPay statuses: SUCCESS, PENDING, FAIL, CLOSE + var status = data?.TryGetProperty("status", out var st) == true ? st.GetString() : null; + var amountKobo = data?.TryGetProperty("amount", out var am) == true ? am.GetInt64() : 0L; + var currency = data?.TryGetProperty("currency", out var cur) == true ? cur.GetString() : "NGN"; + + var paymentStatus = status == "SUCCESS" ? PaymentStatus.Successful + : status == "PENDING" ? PaymentStatus.Pending + : PaymentStatus.Failed; + + return new VerificationResponse + { + Success = status == "SUCCESS", + TransactionReference = transactionReference, + Amount = amountKobo / 100m, + Currency = currency ?? "NGN", + Status = paymentStatus, + Message = status == "SUCCESS" ? "Payment verified successfully" : $"Payment status: {status}", + GatewayResponse = new Dictionary { ["status"] = status ?? string.Empty } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error verifying OPay payment: {Ref}", transactionReference); + throw new PaymentGatewayException("Failed to verify OPay payment", ex); + } + } + + public async Task RefundPaymentAsync(RefundRequest request) + { + try + { + var payload = new + { + merchantId = _config.Opay.MerchantId, + reference = request.TransactionReference, + amount = new { total = (long)(request.Amount * 100), currency = "NGN" }, + reason = request.Reason ?? "Customer request" + }; + + var json = JsonSerializer.Serialize(payload); + var signature = GenerateSignature(json); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {_config.Opay.PublicKey}"); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("MerchantId", _config.Opay.MerchantId); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Signature", signature); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json"); + + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync($"{BaseUrl}/international/cashier/refund", content); + + var responseBody = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("OPay refund response: {Body}", responseBody); + + var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + + var code = root.TryGetProperty("code", out var c) ? c.GetString() : null; + var isSuccess = code == "00000"; + var msg = root.TryGetProperty("message", out var m) ? m.GetString() : null; + + return new RefundResponse + { + Success = isSuccess, + RefundReference = request.TransactionReference, + TransactionReference = request.TransactionReference, + Amount = request.Amount, + Message = isSuccess ? "Refund initiated successfully" : (msg ?? "Refund failed"), + Status = isSuccess ? PaymentStatus.Refunded : PaymentStatus.Failed + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing OPay refund: {Ref}", request.TransactionReference); + throw new PaymentGatewayException("Failed to process OPay refund", ex); + } + } +} diff --git a/PayBridge.SDK/Gateways/RemitaGateway.cs b/PayBridge.SDK/Gateways/RemitaGateway.cs index 877804c..024351d 100644 --- a/PayBridge.SDK/Gateways/RemitaGateway.cs +++ b/PayBridge.SDK/Gateways/RemitaGateway.cs @@ -11,7 +11,7 @@ using PayBridge.SDK.Exceptions; using PayBridge.SDK.Interfaces; -namespace PayBridge.SDK.Gateways; +namespace PayBridge.SDK; /// /// Remita payment gateway implementation for Nigerian payments. diff --git a/PayBridge.SDK/Services/PaymentService.cs b/PayBridge.SDK/Services/PaymentService.cs index caedd97..b7a5f5f 100644 --- a/PayBridge.SDK/Services/PaymentService.cs +++ b/PayBridge.SDK/Services/PaymentService.cs @@ -249,7 +249,7 @@ private PaymentGatewayType SelectBestGateway(PaymentRequest request) switch (request.Currency?.ToUpper()) { case "NGN": - return ChooseAvailableGateway(PaymentGatewayType.Monnify, PaymentGatewayType.Squad, PaymentGatewayType.Korapay, PaymentGatewayType.Interswitch, PaymentGatewayType.Remita, PaymentGatewayType.Paystack, PaymentGatewayType.Flutterwave); + return ChooseAvailableGateway(PaymentGatewayType.Monnify, PaymentGatewayType.Squad, PaymentGatewayType.Korapay, PaymentGatewayType.Interswitch, PaymentGatewayType.Remita, PaymentGatewayType.Opay, PaymentGatewayType.Paystack, PaymentGatewayType.Flutterwave); case "KES": case "GHS": @@ -347,6 +347,10 @@ private PaymentGatewayType DetermineGatewayFromReference(string transactionRefer return PaymentGatewayType.Remita; } + if (transactionReference.StartsWith("OP_")) + { + return PaymentGatewayType.Opay; + } // Default to configured default gateway _logger.LogWarning("Could not determine gateway from reference: {Reference}", transactionReference); return _config.DefaultGateway;