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 @@ -18,6 +18,7 @@ public class PaymentGatewayConfig
public InterswitchConfig Interswitch { get; set; } = new();
public RemitaConfig Remita { get; set; } = new();
public OpayConfig Opay { get; set; } = new();
public DpoGroupConfig DpoGroup { get; set; } = new();
}

public class PaystackConfig
Expand Down Expand Up @@ -123,3 +124,21 @@ public class OpayConfig
public string SecretKey { get; set; } = string.Empty;
public bool IsSandbox { get; set; } = false;
}

public class DpoGroupConfig
{
/// <summary>
/// DPO Company Token (from your DPO merchant account).
/// </summary>
public string CompanyToken { get; set; } = string.Empty;

/// <summary>
/// Default payment currency (e.g. KES, GHS, UGX, ZAR, USD).
/// </summary>
public string PaymentCurrency { get; set; } = "USD";

/// <summary>
/// Set to true to use DPO 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 @@ -67,5 +67,10 @@ public enum PaymentGatewayType
/// <summary>
/// OPay payment gateway (Nigeria / Africa)
/// </summary>
Opay = 12
Opay = 12,

/// <summary>
/// DPO Group payment gateway (Africa - 19+ countries)
/// </summary>
DpoGroup = 13
}
7 changes: 4 additions & 3 deletions PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,7 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
services.AddScoped<KorapayGateway>();
services.AddScoped<IPaymentGateway>(sp => sp.GetRequiredService<KorapayGateway>());
services.AddScoped<InterswitchGateway>();
services.AddScoped<RemitaGateway>();
services.AddScoped<OpayGateway>();
return;
AddGatewayRegistration<DpoGroupGateway>(services);
}

// Register only the enabled gateways
Expand Down Expand Up @@ -182,6 +180,9 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
case PaymentGatewayType.Opay:
services.AddScoped<OpayGateway>();
break;
case PaymentGatewayType.DpoGroup:
AddGatewayRegistration<DpoGroupGateway>(services);
break;
Comment thread
Copilot marked this conversation as resolved.
}
}
}
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 @@ -47,6 +47,7 @@ public Dictionary<PaymentGatewayType, IPaymentGateway> CreateGateways()
TryAddGateway(gateways, PaymentGatewayType.Interswitch);
TryAddGateway(gateways, PaymentGatewayType.Remita);
TryAddGateway(gateways, PaymentGatewayType.Opay);
TryAddGateway(gateways, PaymentGatewayType.DpoGroup);
}
else
{
Expand Down Expand Up @@ -92,6 +93,7 @@ private void TryAddGateway(Dictionary<PaymentGatewayType, IPaymentGateway> gatew
PaymentGatewayType.Interswitch => _serviceProvider.GetService<InterswitchGateway>(),
PaymentGatewayType.Remita => _serviceProvider.GetService<RemitaGateway>(),
PaymentGatewayType.Opay => _serviceProvider.GetService<OpayGateway>(),
PaymentGatewayType.DpoGroup => _serviceProvider.GetService<DpoGroupGateway>(),
_ => null
};

Expand Down
231 changes: 231 additions & 0 deletions PayBridge.SDK/Gateways/DpoGroupGateway.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
using System.Text;
using System.Xml.Linq;
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>
/// DPO Group payment gateway implementation (Africa - 19+ countries).
/// Supports KES, GHS, UGX, TZS, ZAR, RWF, USD, EUR and more.
/// Uses XML-based API v6 at https://secure.3gdirectpay.com/API/v6/
/// </summary>
public class DpoGroupGateway : IPaymentGateway
{
private readonly PaymentGatewayConfig _config;
private readonly HttpClient _httpClient;
private readonly ILogger<DpoGroupGateway> _logger;

private const string LiveApiUrl = "https://secure.3gdirectpay.com/API/v6/";
private const string SandboxApiUrl = "https://secure.3gdirectpay.com/API/v6/";
private const string LivePayUrl = "https://secure.3gdirectpay.com/payv2.php";
private const string SandboxPayUrl = "https://secure.3gdirectpay.com/payv2.php";
private const string TxRefPrefix = "DPO_";

public DpoGroupGateway(
PaymentGatewayConfig config,
IHttpClientFactory httpClientFactory,
ILogger<DpoGroupGateway> logger)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_httpClient = httpClientFactory.CreateClient(nameof(DpoGroupGateway));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

public PaymentGatewayType GatewayType => PaymentGatewayType.DpoGroup;

private string ApiUrl => _config.DpoGroup.IsSandbox ? SandboxApiUrl : LiveApiUrl;
private string PayUrl => _config.DpoGroup.IsSandbox ? SandboxPayUrl : LivePayUrl;

public async Task<PaymentResponse> CreatePaymentAsync(PaymentRequest request)
{
try
{
var txRef = $"{TxRefPrefix}{Guid.NewGuid():N}";
var currency = request.Currency ?? _config.DpoGroup.PaymentCurrency;
var amount = request.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
// Build XML payload for createToken
var xml = new XDocument(
new XElement("API3G",
new XElement("CompanyToken", _config.DpoGroup.CompanyToken),
new XElement("Request", "createToken"),
new XElement("Transaction",
new XElement("PaymentAmount", amount),
new XElement("PaymentCurrency", currency),
new XElement("CompanyRef", txRef),
new XElement("RedirectURL", request.RedirectUrl ?? string.Empty),
new XElement("BackURL", request.RedirectUrl ?? string.Empty),
new XElement("CompanyRefUnique", "0"),
new XElement("PTL", "5")
),
new XElement("Services",
new XElement("Service",
new XElement("ServiceType", "3854"),
new XElement("ServiceDescription", request.Description ?? "Payment"),
new XElement("ServiceDate", DateTime.UtcNow.ToString("yyyy/MM/dd HH:mm"))
)
)
)
);

var xmlString = xml.ToString();
_logger.LogDebug("DPO createToken request prepared for CompanyRef: {CompanyRef}", txRef);
var content = new StringContent(xmlString, Encoding.UTF8, "application/xml");
var response = await _httpClient.PostAsync(ApiUrl, content);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogDebug("DPO createToken response: {Body}", responseBody);

Comment on lines +80 to +83
var doc = XDocument.Parse(responseBody);
var result = doc.Root?.Element("Result")?.Value;
var resultExplanation = doc.Root?.Element("ResultExplanation")?.Value;

if (result != "000")
{
return new PaymentResponse
{
Success = false,
Message = $"DPO token creation failed: {resultExplanation}",
Status = PaymentStatus.Failed
};
}

var transToken = doc.Root?.Element("TransToken")?.Value ?? string.Empty;
var checkoutUrl = $"{PayUrl}?ID={transToken}";

return new PaymentResponse
{
Success = true,
TransactionReference = $"{TxRefPrefix}{transToken}",
CheckoutUrl = checkoutUrl,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Message = "Payment initiated successfully",
Status = PaymentStatus.Pending,
GatewayResponse = new Dictionary<string, string>
{
["transToken"] = transToken,
["companyRef"] = txRef,
["checkoutUrl"] = checkoutUrl
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error initiating DPO Group payment");
throw new PaymentGatewayException("Failed to initiate DPO Group payment", ex);
}
}

public async Task<VerificationResponse> VerifyPaymentAsync(string transactionReference)
{
try
{
// For DPO, transactionReference is the TransToken returned at creation
// Strip DPO_ prefix if present to get the companyRef, but we need the TransToken
// Convention: we store the TransToken as the gateway's transaction reference
var xml = new XDocument(
new XElement("API3G",
new XElement("CompanyToken", _config.DpoGroup.CompanyToken),
new XElement("Request", "verifyToken"),
new XElement("TransactionToken", transactionReference.StartsWith(TxRefPrefix, StringComparison.Ordinal)
? transactionReference.Substring(TxRefPrefix.Length)
: transactionReference)
)
Comment thread
Copilot marked this conversation as resolved.
);

var xmlString = xml.ToString();
var content = new StringContent(xmlString, Encoding.UTF8, "application/xml");
var response = await _httpClient.PostAsync(ApiUrl, content);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogDebug("DPO verifyToken response: {Body}", responseBody);

var doc = XDocument.Parse(responseBody);
var result = doc.Root?.Element("Result")?.Value;
var resultExplanation = doc.Root?.Element("ResultExplanation")?.Value;
var customerName = doc.Root?.Element("CustomerName")?.Value ?? string.Empty;
var customerEmail = doc.Root?.Element("CustomerEmail")?.Value ?? string.Empty;
var transactionAmountStr = doc.Root?.Element("TransactionAmount")?.Value ?? "0";
var transactionCurrency = doc.Root?.Element("TransactionCurrency")?.Value ?? _config.DpoGroup.PaymentCurrency;
var companyRef = doc.Root?.Element("CompanyRef")?.Value ?? transactionReference;

decimal.TryParse(transactionAmountStr, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var transactionAmount);

// DPO result codes: "000" = payment verified successfully
var paymentStatus = result == "000" ? PaymentStatus.Successful
: result == "001" ? PaymentStatus.Pending
: PaymentStatus.Failed;

return new VerificationResponse
{
Success = result == "000",
TransactionReference = companyRef,
Amount = transactionAmount,
Comment on lines +163 to +167
Currency = transactionCurrency,
Status = paymentStatus,
Message = result == "000" ? "Payment verified successfully" : (resultExplanation ?? $"Payment status code: {result}"),
GatewayResponse = new Dictionary<string, string>
{
["result"] = result ?? string.Empty,
["resultExplanation"] = resultExplanation ?? string.Empty,
["customerName"] = customerName,
["customerEmail"] = customerEmail
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying DPO Group payment: {Ref}", transactionReference);
throw new PaymentGatewayException("Failed to verify DPO Group payment", ex);
}
}

public async Task<RefundResponse> RefundPaymentAsync(RefundRequest request)
{
try
{
// DPO Group refunds are initiated via refundToken request
var xml = new XDocument(
new XElement("API3G",
new XElement("CompanyToken", _config.DpoGroup.CompanyToken),
new XElement("Request", "refundToken"),
new XElement("TransactionToken", request.TransactionReference.StartsWith(TxRefPrefix, StringComparison.Ordinal)
? request.TransactionReference.Substring(TxRefPrefix.Length)
: request.TransactionReference),
new XElement("refundAmount", request.Amount.ToString("F2")),
new XElement("refundDetails", request.Reason ?? "Customer request")
)
);

var xmlString = xml.ToString();
var content = new StringContent(xmlString, Encoding.UTF8, "application/xml");
var response = await _httpClient.PostAsync(ApiUrl, content);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogDebug("DPO refundToken response: {Body}", responseBody);

var doc = XDocument.Parse(responseBody);
var result = doc.Root?.Element("Result")?.Value;
var resultExplanation = doc.Root?.Element("ResultExplanation")?.Value;
var isSuccess = result == "000";

return new RefundResponse
{
Success = isSuccess,
RefundReference = request.TransactionReference,
TransactionReference = request.TransactionReference,
Amount = request.Amount,
Message = isSuccess ? "Refund initiated successfully" : (resultExplanation ?? "Refund failed"),
Status = isSuccess ? PaymentStatus.Refunded : PaymentStatus.Failed
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing DPO Group refund: {Ref}", request.TransactionReference);
throw new PaymentGatewayException("Failed to process DPO Group refund", ex);
}
}
}
8 changes: 7 additions & 1 deletion PayBridge.SDK/Services/PaymentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ private PaymentGatewayType SelectBestGateway(PaymentRequest request)
case "UGX":
case "TZS":
case "ZAR":
return ChooseAvailableGateway(PaymentGatewayType.Flutterwave, PaymentGatewayType.Paystack);
case "RWF":
return ChooseAvailableGateway(PaymentGatewayType.DpoGroup, PaymentGatewayType.Flutterwave, PaymentGatewayType.Paystack);

case "BHD":
return ChooseAvailableGateway(PaymentGatewayType.BenefitPay);
Expand Down Expand Up @@ -351,6 +352,11 @@ private PaymentGatewayType DetermineGatewayFromReference(string transactionRefer
{
return PaymentGatewayType.Opay;
}

if (transactionReference.StartsWith("DPO_"))
{
return PaymentGatewayType.DpoGroup;
}
// Default to configured default gateway
_logger.LogWarning("Could not determine gateway from reference: {Reference}", transactionReference);
return _config.DefaultGateway;
Expand Down