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
22 changes: 22 additions & 0 deletions PayBridge.SDK/Dtos/PaymentGatewayConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class PaymentGatewayConfig
public MonnifyConfig Monnify { get; set; } = new();
public SquadConfig Squad { get; set; } = new();
public KorapayConfig Korapay { get; set; } = new();
public InterswitchConfig Interswitch { get; set; } = new();
}

public class PaystackConfig
Expand Down Expand Up @@ -83,3 +84,24 @@ public class KorapayConfig
public string PublicKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
}

public class InterswitchConfig
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;

/// <summary>
/// Merchant code assigned to your Interswitch account.
/// </summary>
public string MerchantCode { get; set; } = string.Empty;

/// <summary>
/// Payment item code for Quickteller / Webpay.
/// </summary>
public string PaymentItemCode { get; set; } = string.Empty;

/// <summary>
/// Set to true to use Interswitch sandbox (test) environment.
/// </summary>
public bool IsSandbox { get; set; } = false;
}
7 changes: 6 additions & 1 deletion PayBridge.SDK/Enums/PaymentGatewayType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,10 @@ public enum PaymentGatewayType
/// <summary>
/// Korapay payment gateway (Nigeria/Africa)
/// </summary>
Korapay = 9
Korapay = 9,

/// <summary>
/// Interswitch payment gateway (Nigeria - Quickteller / Webpay)
/// </summary>
Interswitch = 10
}
5 changes: 5 additions & 0 deletions PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
services.AddScoped<SquadGateway>();
services.AddScoped<KorapayGateway>();
services.AddScoped<IPaymentGateway>(sp => sp.GetRequiredService<KorapayGateway>());
services.AddScoped<InterswitchGateway>();

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 | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== PaymentService dependency shape =="
rg -n -C2 'IEnumerable<IPaymentGateway>|ToDictionary\(g => g\.GatewayType\)' PayBridge.SDK/Services/PaymentService.cs

echo
echo "== Interswitch registrations =="
rg -n -C2 'InterswitchGateway|PaymentGatewayType\.Interswitch|IPaymentGateway' \
  PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs \
  PayBridge.SDK/Factories/PaymentGatewayFactory.cs

Repository: teesofttech/PayBridge

Length of output: 6282


Fix InterswitchGateway registration so PaymentService can resolve it via IEnumerable<IPaymentGateway>

PaymentService injects IEnumerable<IPaymentGateway> and builds _gateways with gateways.ToDictionary(g => g.GatewayType), but InterswitchGateway is only registered as the concrete type (services.AddScoped<InterswitchGateway>();) in both the default path (line 140) and case PaymentGatewayType.Interswitch (178-180). Unlike other gateways, it is not also registered as IPaymentGateway, so PaymentGatewayType.Interswitch won’t be present in PaymentService’s gateway map.

Suggested fix
 services.AddScoped<InterswitchGateway>();
+services.AddScoped<IPaymentGateway>(sp => sp.GetRequiredService<InterswitchGateway>());

...
 case PaymentGatewayType.Interswitch:
     services.AddScoped<InterswitchGateway>();
+    services.AddScoped<IPaymentGateway>(sp => sp.GetRequiredService<InterswitchGateway>());
     break;
🤖 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/Externsions/IServiceCollectionExtensions.cs` at line 140,
InterswitchGateway is only registered as the concrete type
(services.AddScoped<InterswitchGateway>), so PaymentService (which resolves
IEnumerable<IPaymentGateway>) never receives it; change the registration to
register it as IPaymentGateway as well (e.g., register InterswitchGateway with
the IPaymentGateway service) in the same places where InterswitchGateway is
currently added (the default registration at
services.AddScoped<InterswitchGateway>() and the PaymentGatewayType.Interswitch
branch) so PaymentService can discover it via IEnumerable<IPaymentGateway>.

return;
}

Expand Down Expand Up @@ -174,6 +175,10 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
services.AddScoped<KorapayGateway>();
services.AddScoped<IPaymentGateway>(sp => sp.GetRequiredService<KorapayGateway>());
break;
case PaymentGatewayType.Interswitch:
services.AddScoped<InterswitchGateway>();
services.AddScoped<IPaymentGateway>(sp => sp.GetRequiredService<InterswitchGateway>());
break;
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions PayBridge.SDK/Factories/PaymentGatewayFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public Dictionary<PaymentGatewayType, IPaymentGateway> CreateGateways()
TryAddGateway(gateways, PaymentGatewayType.Monnify);
TryAddGateway(gateways, PaymentGatewayType.Squad);
TryAddGateway(gateways, PaymentGatewayType.Korapay);
TryAddGateway(gateways, PaymentGatewayType.Interswitch);
}
else
{
Expand Down Expand Up @@ -86,6 +87,7 @@ private void TryAddGateway(Dictionary<PaymentGatewayType, IPaymentGateway> gatew
PaymentGatewayType.Monnify => _serviceProvider.GetService<MonnifyGateway>(),
PaymentGatewayType.Squad => _serviceProvider.GetService<SquadGateway>(),
PaymentGatewayType.Korapay => _serviceProvider.GetService<KorapayGateway>(),
PaymentGatewayType.Interswitch => _serviceProvider.GetService<InterswitchGateway>(),
_ => null
};

Expand Down
318 changes: 318 additions & 0 deletions PayBridge.SDK/Gateways/InterswitchGateway.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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.Interfaces;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace PayBridge.SDK;

/// <summary>
/// Interswitch payment gateway — Nigeria (Quickteller / Webpay).
/// The largest Nigerian fintech network supporting ATM, POS, web, and USSD.
/// Docs: https://developer.interswitchgroup.com/
/// </summary>
public class InterswitchGateway : IPaymentGateway
{
private readonly PaymentGatewayConfig _config;
private readonly HttpClient _httpClient;
private readonly ILogger<InterswitchGateway> _logger;

private const string LiveUrl = "https://api.interswitchgroup.com";
private const string SandboxUrl = "https://sandbox.interswitchng.com";

public PaymentGatewayType GatewayType => PaymentGatewayType.Interswitch;

public InterswitchGateway(IOptions<PaymentGatewayConfig> config, ILogger<InterswitchGateway> logger, IHttpClientFactory httpClientFactory)
{
_config = config.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));

if (string.IsNullOrEmpty(_config.Interswitch.ClientId))
throw new InvalidOperationException("Interswitch ClientId is required");

if (string.IsNullOrEmpty(_config.Interswitch.ClientSecret))
throw new InvalidOperationException("Interswitch ClientSecret is required");

if (string.IsNullOrEmpty(_config.Interswitch.MerchantCode))
throw new InvalidOperationException("Interswitch MerchantCode is required");
Comment on lines +43 to +44

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

Validate PaymentItemCode in constructor like other required credentials.

CreatePaymentAsync always sends payableCode; allowing empty config here shifts a deterministic misconfiguration into runtime API failures.

Suggested fix
 if (string.IsNullOrEmpty(_config.Interswitch.MerchantCode))
     throw new InvalidOperationException("Interswitch MerchantCode is required");

+if (string.IsNullOrEmpty(_config.Interswitch.PaymentItemCode))
+    throw new InvalidOperationException("Interswitch PaymentItemCode is required");
📝 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
if (string.IsNullOrEmpty(_config.Interswitch.MerchantCode))
throw new InvalidOperationException("Interswitch MerchantCode is required");
if (string.IsNullOrEmpty(_config.Interswitch.MerchantCode))
throw new InvalidOperationException("Interswitch MerchantCode is required");
if (string.IsNullOrEmpty(_config.Interswitch.PaymentItemCode))
throw new InvalidOperationException("Interswitch PaymentItemCode is required");
🤖 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/InterswitchGateway.cs` around lines 43 - 44, The
constructor for InterswitchGateway currently validates MerchantCode but not
PaymentItemCode, allowing runtime failures in CreatePaymentAsync; add a guard in
the InterswitchGateway constructor to validate
_config.Interswitch.PaymentItemCode (same pattern as the existing MerchantCode
check) and throw an InvalidOperationException with a clear message like
"Interswitch PaymentItemCode is required" so misconfiguration is caught at
startup rather than at CreatePaymentAsync when payableCode is sent.


var baseUrl = _config.Interswitch.IsSandbox ? SandboxUrl : LiveUrl;

_httpClient = httpClientFactory.CreateClient();
_httpClient.BaseAddress = new Uri(baseUrl);
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}

// -----------------------------------------------------------------------
// Interswitch uses OAuth 2.0 client_credentials flow for access tokens.
// -----------------------------------------------------------------------

private async Task<string> GetAccessTokenAsync()
{
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_config.Interswitch.ClientId}:{_config.Interswitch.ClientSecret}"));

using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/passport/oauth/token");
tokenRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["scope"] = "profile"
});

var response = await _httpClient.SendAsync(tokenRequest);
var body = await response.Content.ReadAsStringAsync();

if (response.IsSuccessStatusCode)
{
_logger.LogDebug("Interswitch auth succeeded ({StatusCode})", response.StatusCode);
}
else
{
_logger.LogDebug("Interswitch auth failed ({StatusCode}): {Body}", response.StatusCode, body);
}

using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;

if (response.IsSuccessStatusCode && root.TryGetProperty("access_token", out var token))
return token.GetString() ?? throw new InvalidOperationException("Interswitch returned an empty access token");

throw new InvalidOperationException("Failed to obtain Interswitch access token");
}

// -----------------------------------------------------------------------
// Interswitch requires HMAC-SHA512 request signing for Webpay.
// Current implementation: Base64(HMACSHA512(key=clientSecret, message=requestRef + timestamp + clientId + clientSecret))

private static string GenerateSignature(string requestRef, string timestamp, string clientId, string clientSecret)
{
var rawSignature = $"{requestRef}{timestamp}{clientId}{clientSecret}";
var keyBytes = Encoding.UTF8.GetBytes(clientSecret);
using var hmac = new HMACSHA512(keyBytes);
var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawSignature));
return Convert.ToBase64String(hashBytes);
}

public async Task<PaymentResponse> CreatePaymentAsync(PaymentRequest request)
{
_logger.LogInformation("Creating Interswitch payment for customer {Email}", request.CustomerEmail);

try
{
var accessToken = await GetAccessTokenAsync();
var txRef = $"ISW_{Guid.NewGuid():N}";
Comment on lines +110 to +111
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var signature = GenerateSignature(txRef, timestamp, _config.Interswitch.ClientId, _config.Interswitch.ClientSecret);

var iswRequest = new
{
merchantCode = _config.Interswitch.MerchantCode,
payableCode = _config.Interswitch.PaymentItemCode,
merchantCustomerName = request.CustomerName,
merchantCustomerEmail = request.CustomerEmail,
merchantCustomerPhone = request.CustomerPhone,
amount = (int)(request.Amount * 100), // in kobo
currencyCode = "566", // ISO 4217 numeric code for NGN
Comment on lines +122 to +123

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

Use rounded checked conversion for kobo amounts.

Direct (int)(amount * 100) truncates fractional values and can overflow. That can send wrong amounts to the gateway.

Suggested fix
+ private static long ToKobo(decimal amount) =>
+     checked((long)Math.Round(amount * 100m, MidpointRounding.AwayFromZero));

...
- amount = (int)(request.Amount * 100), // in kobo
+ amount = ToKobo(request.Amount), // in kobo

...
- amount = (int)(request.Amount * 100),
+ amount = ToKobo(request.Amount),

Also applies to: 261-262

🤖 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/InterswitchGateway.cs` around lines 116 - 117, In
InterswitchGateway replace the unsafe truncating cast for request.Amount ->
amount (currently "(int)(request.Amount * 100)") with a rounded, checked
conversion: compute the kobo value using rounding (e.g. Math.Round or equivalent
with MidpointRounding.AwayFromZero) and then cast inside a checked context so
overflow raises an exception you can handle; ensure you catch/handle
OverflowException and log/return a clear error rather than sending a wrong
amount to the gateway. Apply the same change to the other occurrence that sets
amount (the second block similar to currencyCode = "566") so both conversions
use rounded+checked conversion and proper error handling.

transactionReference = txRef,
redirectUrl = request.RedirectUrl,
requestType = "1" // 1 = redirect / hosted page
};

using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v2/purchases");
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
httpRequest.Headers.Add("Signature", signature);
httpRequest.Headers.Add("Timestamp", timestamp);
httpRequest.Headers.Add("Nonce", Guid.NewGuid().ToString("N"));
httpRequest.Content = new StringContent(
JsonSerializer.Serialize(iswRequest), Encoding.UTF8, "application/json");

var response = await _httpClient.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();

_logger.LogDebug("Interswitch create payment response: {Response}", responseBody);

using var jsonDocument = JsonDocument.Parse(responseBody);
var root = jsonDocument.RootElement;

if (response.IsSuccessStatusCode && root.TryGetProperty("redirectUrl", out var checkoutUrl))
{
return new PaymentResponse
{
Success = true,
TransactionReference = txRef,
Message = "Interswitch payment initiated successfully",
Status = PaymentStatus.Pending,
CheckoutUrl = checkoutUrl.GetString() ?? string.Empty,
GatewayResponse = new Dictionary<string, string>
{
{ "transaction_reference", txRef }
}
};
}

var errorMessage = root.TryGetProperty("responseDescription", out var desc)
? desc.GetString() ?? "Unknown error"
: "Unknown error occurred";

_logger.LogError("Interswitch payment initiation failed: {Error}", errorMessage);
return new PaymentResponse { Success = false, Message = errorMessage, Status = PaymentStatus.Failed };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating Interswitch payment");
throw new Exception("Failed to create payment with Interswitch", ex);
}
}

public async Task<VerificationResponse> VerifyPaymentAsync(string transactionReference)
{
_logger.LogInformation("Verifying Interswitch payment: {Reference}", transactionReference);

try
{
var accessToken = await GetAccessTokenAsync();
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var signature = GenerateSignature(transactionReference, timestamp, _config.Interswitch.ClientId, _config.Interswitch.ClientSecret);

using var httpRequest = new HttpRequestMessage(HttpMethod.Get,
$"/api/v2/purchases?merchantCode={_config.Interswitch.MerchantCode}&transactionReference={transactionReference}");
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
httpRequest.Headers.Add("Signature", signature);
httpRequest.Headers.Add("Timestamp", timestamp);
httpRequest.Headers.Add("Nonce", Guid.NewGuid().ToString("N"));

var response = await _httpClient.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();

_logger.LogDebug("Interswitch verify response: {Response}", responseBody);

using var jsonDocument = JsonDocument.Parse(responseBody);
var root = jsonDocument.RootElement;

if (response.IsSuccessStatusCode)
{
var responseCode = root.TryGetProperty("responseCode", out var rc)
? rc.GetString() ?? string.Empty
: string.Empty;

// Interswitch response codes: 00 = success, others = failure/pending
var paymentStatus = responseCode switch
{
"00" => PaymentStatus.Successful,
"T0" => PaymentStatus.Pending, // transaction pending
_ => PaymentStatus.Failed
};

return new VerificationResponse
{
Success = true,
TransactionReference = transactionReference,
Message = root.TryGetProperty("responseDescription", out var desc)
? desc.GetString() ?? "Verification complete"
: "Verification complete",
Status = paymentStatus,
Amount = root.TryGetProperty("amount", out var amount)
? amount.GetDecimal() / 100
: 0,
Currency = "NGN",
PaymentDate = root.TryGetProperty("transactionDate", out var txDate)
&& !string.IsNullOrEmpty(txDate.GetString())
? DateTime.Parse(txDate.GetString()!)
: DateTime.UtcNow,
PaymentMethod = root.TryGetProperty("paymentChannelName", out var channel)
? channel.GetString() ?? string.Empty
: string.Empty
};
}

return new VerificationResponse
{
Success = false,
TransactionReference = transactionReference,
Message = "Verification request failed",
Status = PaymentStatus.Failed
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying Interswitch payment");
throw new Exception("Failed to verify payment with Interswitch", ex);
}
}

public async Task<RefundResponse> RefundPaymentAsync(RefundRequest request)
{
_logger.LogInformation("Processing Interswitch refund for: {Reference}", request.TransactionReference);

try
{
var accessToken = await GetAccessTokenAsync();
var refundRef = $"REF_{Guid.NewGuid():N}";
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var signature = GenerateSignature(refundRef, timestamp, _config.Interswitch.ClientId, _config.Interswitch.ClientSecret);

var refundRequest = new
{
merchantCode = _config.Interswitch.MerchantCode,
transactionReference = request.TransactionReference,
refundReference = refundRef,
amount = (int)(request.Amount * 100),
currencyCode = "566",
remarks = request.Reason ?? "Customer request"
};

using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v2/refunds");
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
httpRequest.Headers.Add("Signature", signature);
httpRequest.Headers.Add("Timestamp", timestamp);
httpRequest.Headers.Add("Nonce", Guid.NewGuid().ToString("N"));
httpRequest.Content = new StringContent(
JsonSerializer.Serialize(refundRequest), Encoding.UTF8, "application/json");

var response = await _httpClient.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();

_logger.LogDebug("Interswitch refund response: {Response}", responseBody);

using var jsonDocument = JsonDocument.Parse(responseBody);
var root = jsonDocument.RootElement;

var responseCode = root.TryGetProperty("responseCode", out var rc)
? rc.GetString() ?? string.Empty
: string.Empty;

if (response.IsSuccessStatusCode && responseCode == "00")
{
return new RefundResponse
{
Success = true,
RefundReference = refundRef,
TransactionReference = request.TransactionReference,
Amount = request.Amount,
Status = PaymentStatus.Refunded,
Message = "Refund processed successfully",
RefundDate = DateTime.UtcNow
};
}

var errorMessage = root.TryGetProperty("responseDescription", out var desc)
? desc.GetString() ?? "Refund failed"
: "Refund request failed";

return new RefundResponse { Success = false, Message = errorMessage, Status = PaymentStatus.Failed };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Interswitch refund");
throw new Exception("Failed to process refund with Interswitch", ex);
}
}
}
Loading