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
19 changes: 19 additions & 0 deletions PayBridge.SDK/Dtos/PaymentGatewayConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -156,3 +157,21 @@ public class PawaPayConfig
/// </summary>
public bool IsSandbox { get; set; } = false;
}

public class PeachPaymentsConfig
{
/// <summary>
/// Peach Payments Entity ID (from your Peach Payments dashboard).
/// </summary>
public string EntityId { get; set; } = string.Empty;

/// <summary>
/// Peach Payments Access Token (Bearer token).
/// </summary>
public string AccessToken { get; set; } = string.Empty;

/// <summary>
/// Set to true to use Peach Payments test/sandbox 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 @@ -77,5 +77,10 @@ public enum PaymentGatewayType
/// <summary>
/// PawaPay payment gateway (Africa - mobile money)
/// </summary>
PawaPay = 14
PawaPay = 14,

/// <summary>
/// Peach Payments gateway (South Africa, Kenya, Nigeria)
/// </summary>
Comment on lines +82 to +84
PeachPayments = 15
}
4 changes: 4 additions & 0 deletions PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
services.AddScoped<RemitaGateway>();
services.AddScoped<OpayGateway>();
services.AddScoped<PawaPayGateway>();
services.AddScoped<PeachPaymentsGateway>();
return;
}

Expand Down Expand Up @@ -190,6 +191,9 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
case PaymentGatewayType.PawaPay:
services.AddScoped<PawaPayGateway>();
break;
case PaymentGatewayType.PeachPayments:
services.AddScoped<PeachPaymentsGateway>();
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 @@ -49,6 +49,7 @@ public Dictionary<PaymentGatewayType, IPaymentGateway> CreateGateways()
TryAddGateway(gateways, PaymentGatewayType.Opay);
TryAddGateway(gateways, PaymentGatewayType.DpoGroup);
TryAddGateway(gateways, PaymentGatewayType.PawaPay);
TryAddGateway(gateways, PaymentGatewayType.PeachPayments);
}
else
{
Expand Down Expand Up @@ -96,6 +97,7 @@ private void TryAddGateway(Dictionary<PaymentGatewayType, IPaymentGateway> gatew
PaymentGatewayType.Opay => _serviceProvider.GetService<OpayGateway>(),
PaymentGatewayType.DpoGroup => _serviceProvider.GetService<DpoGroupGateway>(),
PaymentGatewayType.PawaPay => _serviceProvider.GetService<PawaPayGateway>(),
PaymentGatewayType.PeachPayments => _serviceProvider.GetService<PeachPaymentsGateway>(),
_ => null
};

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

/// <summary>
/// Peach Payments gateway (South Africa, Kenya, Nigeria, Botswana).
/// Currencies: ZAR, KES, NGN, BWP, USD.
/// Auth: Bearer AccessToken + EntityId.
/// Docs: https://developer.peachpayments.com
/// </summary>
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<PeachPaymentsGateway> _logger;

public PeachPaymentsGateway(
PaymentGatewayConfig config,
IHttpClientFactory httpClientFactory,
ILogger<PeachPaymentsGateway> 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<PaymentResponse> CreatePaymentAsync(PaymentRequest request)
{
try
{
var txRef = $"{TxRefPrefix}{Guid.NewGuid():N}";

var formData = new Dictionary<string, string>
{
["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<string, string>
{
["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<VerificationResponse> 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;

Comment on lines +143 to +145
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<string, string>
{
["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<RefundResponse> RefundPaymentAsync(RefundRequest request)
{
try
{
var formData = new Dictionary<string, string>
{
["entityId"] = _config.PeachPayments.EntityId,
["amount"] = request.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
["currency"] = "ZAR",
["paymentType"] = "RF",
Comment on lines +194 to +197
};

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;
Comment on lines +202 to +206
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);
}
}
Comment on lines +188 to +230

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

Hardcoded refund currency breaks multi-currency support.

Line 196 hardcodes "ZAR" as the refund currency, but Peach Payments supports ZAR, KES, NGN, BWP, and USD according to the PR objectives and gateway documentation. Refunds for transactions in other currencies will fail or be rejected by the API.

The RefundRequest doesn't include a currency field, so you'll need to retrieve the original transaction's currency. The calling code in PaymentService.RefundPaymentAsync already loads the transaction from the repository (line 173 in PaymentService.cs), so the currency should be available there.

🐛 Recommended fix: pass currency through RefundRequest

Option 1: Add Currency property to RefundRequest and populate it in PaymentService:

In RefundRequest:

 public class RefundRequest
 {
     public string TransactionReference { get; set; }
     public decimal Amount { get; set; }
+    public string Currency { get; set; }
     public string Reason { get; set; }
 }

In PaymentService.RefundPaymentAsync (around line 173):

+request.Currency = transaction.Currency;
 var response = await _gateways[selectedGateway].RefundPaymentAsync(request);

In PeachPaymentsGateway.RefundPaymentAsync:

 var formData = new Dictionary<string, string>
 {
     ["entityId"]    = _config.PeachPayments.EntityId,
     ["amount"]      = request.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
-    ["currency"]    = "ZAR",
+    ["currency"]    = request.Currency ?? "ZAR",
     ["paymentType"] = "RF",
 };
🤖 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/PeachPaymentsGateway.cs` around lines 188 - 230,
PeachPaymentsGateway.RefundPaymentAsync currently hardcodes "ZAR" for currency
which breaks multi-currency refunds; add a Currency property to RefundRequest,
update PaymentService.RefundPaymentAsync to load the transaction currency and
set request.Currency before calling the gateway, and then change
PeachPaymentsGateway.RefundPaymentAsync to use request.Currency (falling back to
a sensible default or throwing if null) when building formData instead of the
hardcoded "ZAR".


private static string CurrencyToCountry(string currency) => currency.ToUpperInvariant() switch
{
"ZAR" => "ZA",
"KES" => "KE",
"NGN" => "NG",
"BWP" => "BW",
_ => "ZA"
};
}
10 changes: 9 additions & 1 deletion PayBridge.SDK/Services/PaymentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for currency references in PeachPaymentsGateway and related docs
rg -n -C3 'ZAR|KES|NGN|BWP|XOF|XAF|MWK' --glob '*PeachPayments*'

Repository: teesofttech/PayBridge

Length of output: 3517


Fix Peach Payments first-priority routing for unsupported currencies (XOF/XAF/MWK) + check NGN routing

PayBridge.SDK/Gateways/PeachPaymentsGateway.cs explicitly documents supported currencies as ZAR, KES, NGN, BWP, USD and maps only those currencies to countries (CurrencyToCountry has ZAR/KES/NGN/BWP, defaulting to ZA). It does not mention XOF/XAF/MWK, so routing those currencies to PaymentGatewayType.PeachPayments (first priority) appears inconsistent with the gateway’s own documentation.

Also re-check PayBridge.SDK/Services/PaymentService.cs around the NGN routing (line ~252) to ensure it includes PeachPayments as intended, since the existing logic may route NGN without PeachPayments despite Nigeria being in the gateway summary.

🤖 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/Services/PaymentService.cs` at line 265, PaymentService is
currently routing XOF/XAF/MWK to PaymentGatewayType.PeachPayments even though
PeachPaymentsGateway only supports ZAR/KES/NGN/BWP/USD; update the routing logic
in PaymentService (the branch that returns ChooseAvailableGateway(...)) to
exclude PaymentGatewayType.PeachPayments for currencies XOF, XAF, and MWK by
removing it from the gateway list for those currencies, and simultaneously
verify the NGN branch near the existing NGN routing logic still includes
PaymentGatewayType.PeachPayments in its ChooseAvailableGateway call so Nigeria
uses PeachPayments; reference PeachPaymentsGateway.CurrencyToCountry,
PaymentGatewayType.PeachPayments, and the ChooseAvailableGateway call when
making these adjustments.


Comment on lines 262 to +266
case "BWP":
return ChooseAvailableGateway(PaymentGatewayType.PeachPayments, PaymentGatewayType.DpoGroup);

case "BHD":
return ChooseAvailableGateway(PaymentGatewayType.BenefitPay);
Expand Down Expand Up @@ -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;
Expand Down