Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions PayBridge.SDK/Dtos/PaymentGatewayConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,3 +106,11 @@ public class InterswitchConfig
/// </summary>
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;
}
7 changes: 6 additions & 1 deletion PayBridge.SDK/Enums/PaymentGatewayType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,10 @@ public enum PaymentGatewayType
/// <summary>
/// Interswitch payment gateway (Nigeria - Quickteller / Webpay)
/// </summary>
Interswitch = 10
Interswitch = 10,

/// <summary>
/// Remita payment gateway (Nigeria)
/// </summary>
Remita = 11
}
5 changes: 5 additions & 0 deletions PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -135,6 +136,7 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
services.AddScoped<KorapayGateway>();
services.AddScoped<IPaymentGateway>(sp => sp.GetRequiredService<KorapayGateway>());
services.AddScoped<InterswitchGateway>();
services.AddScoped<RemitaGateway>();
return;
}

Expand Down Expand Up @@ -174,6 +176,9 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
services.AddScoped<InterswitchGateway>();
services.AddScoped<IPaymentGateway>(sp => sp.GetRequiredService<InterswitchGateway>());
break;
case PaymentGatewayType.Remita:
services.AddScoped<RemitaGateway>();
break;
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions PayBridge.SDK/Factories/PaymentGatewayFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,6 +46,7 @@ public Dictionary<PaymentGatewayType, IPaymentGateway> CreateGateways()
TryAddGateway(gateways, PaymentGatewayType.Squad);
TryAddGateway(gateways, PaymentGatewayType.Korapay);
TryAddGateway(gateways, PaymentGatewayType.Interswitch);
TryAddGateway(gateways, PaymentGatewayType.Remita);
}
else
{
Expand Down Expand Up @@ -88,6 +90,7 @@ private void TryAddGateway(Dictionary<PaymentGatewayType, IPaymentGateway> gatew
PaymentGatewayType.Squad => _serviceProvider.GetService<SquadGateway>(),
PaymentGatewayType.Korapay => _serviceProvider.GetService<KorapayGateway>(),
PaymentGatewayType.Interswitch => _serviceProvider.GetService<InterswitchGateway>(),
PaymentGatewayType.Remita => _serviceProvider.GetService<RemitaGateway>(),
_ => null
};

Expand Down
223 changes: 223 additions & 0 deletions PayBridge.SDK/Gateways/RemitaGateway.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Remita payment gateway implementation for Nigerian payments.
/// Supports government, corporate, and retail payments via Remita's API.
/// </summary>
public class RemitaGateway : IPaymentGateway
{
private readonly PaymentGatewayConfig _config;
private readonly HttpClient _httpClient;
private readonly ILogger<RemitaGateway> _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<RemitaGateway> 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;

/// <summary>
/// Generates Remita API hash: SHA512(merchantId + serviceTypeId + orderId + amount + apiKey)
/// </summary>
Comment thread
teesofttech marked this conversation as resolved.
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<PaymentResponse> 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");
Comment thread
teesofttech marked this conversation as resolved.

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<string, string> { ["RRR"] = rrr ?? string.Empty }
};
Comment on lines +104 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not return success when RRR is missing.

statuscode == "025" with an empty/missing RRR currently returns Success = true and builds an invalid checkout URL. That creates a broken transaction state.

Proposed fix
 var rrr = root.TryGetProperty("RRR", out var rrrProp) ? rrrProp.GetString() : string.Empty;
+if (string.IsNullOrWhiteSpace(rrr))
+{
+    return new PaymentResponse
+    {
+        Success = false,
+        Message = "Remita payment initiation failed: missing RRR",
+        Status = PaymentStatus.Failed
+    };
+}
 var checkoutUrl = _config.Remita.IsSandbox
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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<string, string> { ["RRR"] = rrr ?? string.Empty }
};
var rrr = root.TryGetProperty("RRR", out var rrrProp) ? rrrProp.GetString() : string.Empty;
if (string.IsNullOrWhiteSpace(rrr))
{
return new PaymentResponse
{
Success = false,
Message = "Remita payment initiation failed: missing RRR",
Status = PaymentStatus.Failed
};
}
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<string, string> { ["RRR"] = rrr ?? string.Empty }
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PayBridge.SDK/Gateways/RemitaGateway.cs` around lines 104 - 117, The code
currently treats statuscode "025" as success even if RRR is missing: check the
parsed rrr (from root.TryGetProperty("RRR", out var rrrProp) ?
rrrProp.GetString() : string.Empty) and if it is null or empty, do not return a
successful PaymentResponse; instead return a failed/invalid PaymentResponse
(e.g., Success = false, Status = PaymentStatus.Failed or appropriate enum, and a
clear Message indicating missing RRR) and avoid constructing the checkoutUrl;
adjust the RemitaGateway method that builds the PaymentResponse to validate rrr
before setting Success = true and CheckoutUrl.

}
catch (Exception ex)
{
_logger.LogError(ex, "Error initiating Remita payment");
throw new PaymentGatewayException("Failed to initiate Remita payment", ex);
}
}

public async Task<VerificationResponse> 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<string, string> { ["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<RefundResponse> 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");
Comment on lines +179 to +184

var payload = new
{
merchantId = _config.Remita.MerchantId,
rrr = request.TransactionReference,
amount = (long)(request.Amount * 100),
reason = request.Reason ?? "Customer request"
Comment on lines +186 to +191
Comment on lines +186 to +191

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

In the official Remita reversal API contract, does the rrrfield strictly require the Remita RRR value, or can it accept a merchant order/transaction reference (e.g., internalREM_*)?

💡 Result:

The official Remita reversal/chargeback API contract (the “rrr” field in the Remita Retrieval Reference payment/reversal flow) expects the field value to be the Remita Retrieval Reference itself; it is not a merchant order/transaction reference such as your internal “REM_*”. The contract defines rrr as “Required” and explicitly states it is “This is the Remita Retrieval Reference”. [1] So: you should send the actual Remita Retrieval Reference (RRR) generated/associated with that payer’s transaction, not your internal orderId/transaction reference. [1]

Citations:


Fix Remita refund rrr mapping to send the actual Remita Retrieval Reference (RRR)

In PayBridge.SDK/Gateways/RemitaGateway.cs (lines 186-191), the reversal/refund payload sets rrr = request.TransactionReference. Remita’s contract defines rrr as the Remita Retrieval Reference itself and marks it Required, so this value must be the provider’s RRR for that payer transaction—not an internal merchant/order/transaction reference. Persist the RRR from the payment flow and use that stored RRR when building the rrr field for refunds/reversals.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PayBridge.SDK/Gateways/RemitaGateway.cs` around lines 186 - 191, The
refund/reversal payload in RemitaGateway currently sets rrr =
request.TransactionReference but Remita requires the actual Remita Retrieval
Reference (RRR); replace the mapping to use the persisted RRR value (e.g.,
request.RemitaRetrievalReference or the field where you store the provider's RRR
from the payment flow) when building the payload variable (the rrr property),
ensure callers populate that persisted RRR, and add a guard/validation in the
same method to throw or return an error if the RRR is missing before sending the
refund 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;
Comment on lines +201 to +202

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);
}
}
}
7 changes: 6 additions & 1 deletion PayBridge.SDK/Services/PaymentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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;
Expand Down