diff --git a/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs b/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs index 616c485..dae6b64 100644 --- a/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs +++ b/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs @@ -16,6 +16,7 @@ public class PaymentGatewayConfig public SquadConfig Squad { get; set; } = new(); public KorapayConfig Korapay { get; set; } = new(); public InterswitchConfig Interswitch { get; set; } = new(); + public RemitaConfig Remita { get; set; } = new(); } public class PaystackConfig @@ -105,3 +106,11 @@ public class InterswitchConfig /// public bool IsSandbox { get; set; } = false; } + +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; +} diff --git a/PayBridge.SDK/Enums/PaymentGatewayType.cs b/PayBridge.SDK/Enums/PaymentGatewayType.cs index b146521..6de06d3 100644 --- a/PayBridge.SDK/Enums/PaymentGatewayType.cs +++ b/PayBridge.SDK/Enums/PaymentGatewayType.cs @@ -57,5 +57,10 @@ public enum PaymentGatewayType /// /// Interswitch payment gateway (Nigeria - Quickteller / Webpay) /// - Interswitch = 10 + Interswitch = 10, + + /// + /// Remita payment gateway (Nigeria) + /// + Remita = 11 } \ No newline at end of file diff --git a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs index b741f13..57382d1 100644 --- a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs +++ b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using PayBridge.SDK.Application.Dtos; using PayBridge.SDK.Enums; +using PayBridge.SDK.Gateways; using PayBridge.SDK.Interfaces; namespace PayBridge.SDK; @@ -135,6 +136,7 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); + services.AddScoped(); return; } @@ -174,6 +176,9 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); break; + case PaymentGatewayType.Remita: + services.AddScoped(); + break; } } } diff --git a/PayBridge.SDK/Factories/PaymentGatewayFactory.cs b/PayBridge.SDK/Factories/PaymentGatewayFactory.cs index 9d1ba7a..e848118 100644 --- a/PayBridge.SDK/Factories/PaymentGatewayFactory.cs +++ b/PayBridge.SDK/Factories/PaymentGatewayFactory.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using PayBridge.SDK.Application.Dtos; using PayBridge.SDK.Enums; +using PayBridge.SDK.Gateways; using PayBridge.SDK.Interfaces; namespace PayBridge.SDK; @@ -45,6 +46,7 @@ public Dictionary CreateGateways() TryAddGateway(gateways, PaymentGatewayType.Squad); TryAddGateway(gateways, PaymentGatewayType.Korapay); TryAddGateway(gateways, PaymentGatewayType.Interswitch); + TryAddGateway(gateways, PaymentGatewayType.Remita); } else { @@ -88,6 +90,7 @@ private void TryAddGateway(Dictionary gatew PaymentGatewayType.Squad => _serviceProvider.GetService(), PaymentGatewayType.Korapay => _serviceProvider.GetService(), PaymentGatewayType.Interswitch => _serviceProvider.GetService(), + PaymentGatewayType.Remita => _serviceProvider.GetService(), _ => null }; diff --git a/PayBridge.SDK/Gateways/RemitaGateway.cs b/PayBridge.SDK/Gateways/RemitaGateway.cs new file mode 100644 index 0000000..877804c --- /dev/null +++ b/PayBridge.SDK/Gateways/RemitaGateway.cs @@ -0,0 +1,223 @@ +using System.Net.Http.Headers; +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.Gateways; + +/// +/// Remita payment gateway implementation for Nigerian payments. +/// Supports government, corporate, and retail payments via Remita's API. +/// +public class RemitaGateway : IPaymentGateway +{ + private readonly PaymentGatewayConfig _config; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private const string LiveBaseUrl = "https://login.remita.net/remita"; + private const string SandboxBaseUrl = "https://remitademo.net/remita"; + private const string TxRefPrefix = "REM_"; + + public RemitaGateway( + PaymentGatewayConfig config, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _httpClient = httpClientFactory.CreateClient(nameof(RemitaGateway)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public PaymentGatewayType GatewayType => PaymentGatewayType.Remita; + + private string BaseUrl => _config.Remita.IsSandbox ? SandboxBaseUrl : LiveBaseUrl; + + /// + /// Generates Remita API hash: SHA512(merchantId + serviceTypeId + orderId + amount + apiKey) + /// + private string GenerateHash(string orderId, decimal amount) + { + var amountKobo = (long)(amount * 100); + var raw = $"{_config.Remita.MerchantId}{_config.Remita.ServiceTypeId}{orderId}{amountKobo}{_config.Remita.ApiKey}"; + var bytes = SHA512.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexString(bytes).ToLower(); + } + + public async Task CreatePaymentAsync(PaymentRequest request) + { + try + { + var txRef = $"{TxRefPrefix}{Guid.NewGuid():N}"; + var hash = GenerateHash(txRef, request.Amount); + var authToken = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_config.Remita.MerchantId}:{_config.Remita.ApiKey}:{hash}")); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", + $"remitaConsumerKey={_config.Remita.MerchantId},remitaConsumerToken={authToken}"); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json"); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json"); + + var payload = new + { + serviceTypeId = _config.Remita.ServiceTypeId, + amount = (long)(request.Amount * 100), + orderId = txRef, + payerName = request.CustomerName ?? string.Empty, + payerEmail = request.CustomerEmail, + payerPhone = request.CustomerPhone ?? string.Empty, + description = request.Description ?? "Payment", + currency = request.Currency ?? "NGN" + }; + + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync( + $"{BaseUrl}/exapp/api/v1/send/api/echannelsvc/merchant/api/paymentinit", content); + + var responseBody = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("Remita initiate response: {Body}", responseBody); + + var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + var statusCode = root.TryGetProperty("statuscode", out var sc) ? sc.GetString() : null; + + if (statusCode != "025") + { + var msg = root.TryGetProperty("status", out var s) ? s.GetString() : "Unknown error"; + return new PaymentResponse + { + Success = false, + Message = $"Remita payment initiation failed: {msg}", + Status = PaymentStatus.Failed + }; + } + + var rrr = root.TryGetProperty("RRR", out var rrrProp) ? rrrProp.GetString() : string.Empty; + var checkoutUrl = _config.Remita.IsSandbox + ? $"https://remitademo.net/remita/ecomm/finalize.reg?merchantId={_config.Remita.MerchantId}&hash={hash}&RRR={rrr}" + : $"https://login.remita.net/remita/ecomm/finalize.reg?merchantId={_config.Remita.MerchantId}&hash={hash}&RRR={rrr}"; + + return new PaymentResponse + { + Success = true, + TransactionReference = txRef, + CheckoutUrl = checkoutUrl, + Message = "Payment initiated successfully", + Status = PaymentStatus.Pending, + GatewayResponse = new Dictionary { ["RRR"] = rrr ?? string.Empty } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initiating Remita payment"); + throw new PaymentGatewayException("Failed to initiate Remita payment", ex); + } + } + + public async Task VerifyPaymentAsync(string transactionReference) + { + try + { + var hash = GenerateHash(transactionReference, 0); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation( + "Authorization", + $"remitaConsumerKey={_config.Remita.MerchantId},remitaConsumerToken={hash}"); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json"); + + var response = await _httpClient.GetAsync( + $"{BaseUrl}/exapp/api/v1/send/api/echannelsvc/{_config.Remita.MerchantId}/{transactionReference}/{hash}/orderstatus.reg"); + + var responseBody = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("Remita verify response: {Body}", responseBody); + + var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + + // Remita status: "01" = successful, "02" = processing, others = failed + var status = root.TryGetProperty("status", out var st) ? st.GetString() : null; + var amount = root.TryGetProperty("amount", out var am) ? am.GetDecimal() / 100m : 0m; + + var paymentStatus = status == "01" ? PaymentStatus.Successful + : status == "02" ? PaymentStatus.Pending + : PaymentStatus.Failed; + + return new VerificationResponse + { + Success = status == "01", + TransactionReference = transactionReference, + Amount = amount, + Currency = "NGN", + Status = paymentStatus, + Message = paymentStatus == PaymentStatus.Successful ? "Payment verified successfully" : "Payment not completed", + GatewayResponse = new Dictionary { ["status"] = status ?? string.Empty } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error verifying Remita payment: {Ref}", transactionReference); + throw new PaymentGatewayException("Failed to verify Remita payment", ex); + } + } + + public async Task RefundPaymentAsync(RefundRequest request) + { + try + { + var hash = GenerateHash(request.TransactionReference, request.Amount); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation( + "Authorization", + $"remitaConsumerKey={_config.Remita.MerchantId},remitaConsumerToken={hash}"); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json"); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json"); + + var payload = new + { + merchantId = _config.Remita.MerchantId, + rrr = request.TransactionReference, + amount = (long)(request.Amount * 100), + reason = request.Reason ?? "Customer request" + }; + + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync( + $"{BaseUrl}/exapp/api/v1/send/api/echannelsvc/merchant/api/reversal", content); + + var responseBody = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("Remita refund response: {Body}", responseBody); + + var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + + var statusCode = root.TryGetProperty("statuscode", out var sc) ? sc.GetString() : null; + var isSuccess = statusCode == "025" || statusCode == "00"; + + return new RefundResponse + { + Success = isSuccess, + RefundReference = request.TransactionReference, + TransactionReference = request.TransactionReference, + Amount = request.Amount, + Message = isSuccess ? "Refund initiated successfully" : "Refund failed", + Status = isSuccess ? PaymentStatus.Refunded : PaymentStatus.Failed + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing Remita refund: {Ref}", request.TransactionReference); + throw new PaymentGatewayException("Failed to process Remita refund", ex); + } + } +} diff --git a/PayBridge.SDK/Services/PaymentService.cs b/PayBridge.SDK/Services/PaymentService.cs index 24b3190..caedd97 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.Paystack, PaymentGatewayType.Flutterwave); + return ChooseAvailableGateway(PaymentGatewayType.Monnify, PaymentGatewayType.Squad, PaymentGatewayType.Korapay, PaymentGatewayType.Interswitch, PaymentGatewayType.Remita, PaymentGatewayType.Paystack, PaymentGatewayType.Flutterwave); case "KES": case "GHS": @@ -342,6 +342,11 @@ private PaymentGatewayType DetermineGatewayFromReference(string transactionRefer return PaymentGatewayType.Korapay; } + if (transactionReference.StartsWith("REM_")) + { + return PaymentGatewayType.Remita; + } + // Default to configured default gateway _logger.LogWarning("Could not determine gateway from reference: {Reference}", transactionReference); return _config.DefaultGateway;