-
Notifications
You must be signed in to change notification settings - Fork 3
feat: implement OpayGateway (Nigeria/Africa - mobile money & card) #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,246 @@ | ||
| 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; | ||
|
|
||
| /// <summary> | ||
| /// OPay payment gateway implementation (Nigeria / Africa). | ||
| /// Supports card, bank transfer, USSD, and mobile money payments. | ||
| /// </summary> | ||
| public class OpayGateway : IPaymentGateway | ||
| { | ||
| private readonly PaymentGatewayConfig _config; | ||
| private readonly HttpClient _httpClient; | ||
| private readonly ILogger<OpayGateway> _logger; | ||
|
|
||
| private const string LiveBaseUrl = "https://cashierapi.opayweb.com/api/v3"; | ||
| private const string SandboxBaseUrl = "https://sandboxapi.opaycheckout.com/api/v3"; | ||
| private const string TxRefPrefix = "OP_"; | ||
|
|
||
| public OpayGateway( | ||
| PaymentGatewayConfig config, | ||
| IHttpClientFactory httpClientFactory, | ||
| ILogger<OpayGateway> logger) | ||
| { | ||
| _config = config ?? throw new ArgumentNullException(nameof(config)); | ||
| _httpClient = httpClientFactory.CreateClient(nameof(OpayGateway)); | ||
| _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||
|
Comment on lines
+34
to
+36
|
||
| } | ||
|
|
||
| public PaymentGatewayType GatewayType => PaymentGatewayType.Opay; | ||
|
|
||
| private string BaseUrl => _config.Opay.IsSandbox ? SandboxBaseUrl : LiveBaseUrl; | ||
|
|
||
| /// <summary> | ||
| /// Generates HMAC-SHA512 signature: HMAC512(jsonBody, secretKey) | ||
| /// </summary> | ||
| private string GenerateSignature(string jsonBody) | ||
| { | ||
| var keyBytes = Encoding.UTF8.GetBytes(_config.Opay.SecretKey); | ||
| var msgBytes = Encoding.UTF8.GetBytes(jsonBody); | ||
| var hash = HMACSHA512.HashData(keyBytes, msgBytes); | ||
| return Convert.ToHexString(hash).ToLower(); | ||
|
Comment on lines
+50
to
+51
|
||
| } | ||
|
|
||
| public async Task<PaymentResponse> CreatePaymentAsync(PaymentRequest request) | ||
| { | ||
| try | ||
| { | ||
| var txRef = $"{TxRefPrefix}{Guid.NewGuid():N}"; | ||
| var amountKobo = (long)(request.Amount * 100); | ||
|
|
||
| var payload = new | ||
| { | ||
| merchantId = _config.Opay.MerchantId, | ||
| reference = txRef, | ||
| amount = new { total = amountKobo, currency = request.Currency ?? "NGN" }, | ||
| returnUrl = request.RedirectUrl ?? string.Empty, | ||
| callbackUrl = request.WebhookUrl ?? string.Empty, | ||
| cancelUrl = request.RedirectUrl ?? string.Empty, | ||
|
Comment on lines
+66
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: To authenticate requests for the OPay Cashier create payment API, you must include specific headers in your HTTP request. Authentication Header Format For the cashier creation endpoint, OPay utilizes public key authentication [1][2]. You must provide the following headers [2][3]: Authorization: Bearer {PublicKey} MerchantId: {YourMerchantId} Content-Type: application/json Accept: application/json In this format, {PublicKey} is your OPay merchant account public key, and {YourMerchantId} is your assigned merchant ID [2][3]. Both values can be retrieved from the API Keys & Webhooks section of your OPay merchant dashboard [1]. Required Fields for Create Payment While specific requirements can vary based on regional implementation, the typical JSON payload for a cashier create payment request includes the following fields [2][3]: Amount: The transaction amount. Currency: The currency code (e.g., NGN, EGP) [2][3]. Reference: A unique identifier for the payment order in your system [2][3]. CallbackUrl: The URL where OPay will send status update notifications [2][3]. ReturnUrl: The URL to redirect the customer to after the payment process completes [2][3]. Country: The country code (e.g., NG, EG) [2][3]. ExpireAt: The expiration time for the payment link (often in minutes) [2][3]. Product: An object containing details such as the product name and description [2][3]. UserInfo (Optional but recommended): An object containing details like userId, userMobile, and userName [3]. Note: Ensure you are using the correct endpoint for your region (e.g., sandboxapi.opaycheckout.com for testing) [2]. Always refer to the official OPay documentation portal for the most accurate and region-specific implementation details [1][4][5]. Citations:
🌐 Web query:
💡 Result: In the OPay Cashier Create Payment API, the cancelUrl field is optional [1][2]. It is defined as the URL to which the OPay cashier should redirect your client if they choose to cancel the payment process [1][2]. While not mandatory, providing this URL allows you to manage the user experience when a payment is not completed [2][3]. In contrast, the returnUrl field is typically required to handle the completion of the checkout process [2][3]. Citations:
🌐 Web query:
💡 Result: According to official OPay technical documentation, the requirements for CallbackUrl and ReturnUrl in the Cashier Create Payment API are as follows: 1. ReturnUrl is mandatory. It is explicitly listed as a "required" parameter in the API specifications for Cashier Create Payment and other payment methods [1][2]. Providing an empty string for this parameter will likely cause the API request to fail, as it is required for redirecting the client after the checkout process [1][2]. 2. CallbackUrl is optional. It is documented as an "optional" parameter [1][2]. If you do not provide a CallbackUrl in your API request, OPay will instead send callback notifications to the webhook URL configured in your merchant dashboard [1][2]. Because ReturnUrl is a required field, you must provide a valid URL string to ensure the payment flow functions correctly. While the API documentation defines it as a required string, using an empty string will not satisfy this requirement and is not recommended [1][2]. Citations:
Fix OPay URL handling for
returnUrl = request.RedirectUrl ?? string.Empty,
callbackUrl = request.WebhookUrl ?? string.Empty,
cancelUrl = request.RedirectUrl ?? string.Empty,🤖 Prompt for AI Agents |
||
| expireAt = 30, | ||
| userInfo = new | ||
| { | ||
| userEmail = request.CustomerEmail, | ||
| userName = request.CustomerName ?? string.Empty, | ||
| userMobile = request.CustomerPhone ?? string.Empty | ||
| }, | ||
| product = new | ||
| { | ||
| name = request.Description ?? "Payment", | ||
| description = request.Description ?? "Payment" | ||
| } | ||
| }; | ||
|
|
||
| var json = JsonSerializer.Serialize(payload); | ||
| var signature = GenerateSignature(json); | ||
|
|
||
| _httpClient.DefaultRequestHeaders.Clear(); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {_config.Opay.PublicKey}"); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("MerchantId", _config.Opay.MerchantId); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Signature", signature); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json"); | ||
|
|
||
| var content = new StringContent(json, Encoding.UTF8, "application/json"); | ||
| var response = await _httpClient.PostAsync($"{BaseUrl}/international/cashier/create", content); | ||
|
|
||
| var responseBody = await response.Content.ReadAsStringAsync(); | ||
| _logger.LogDebug("OPay create response: {Body}", responseBody); | ||
|
|
||
| var doc = JsonDocument.Parse(responseBody); | ||
| var root = doc.RootElement; | ||
|
Comment on lines
+98
to
+99
|
||
|
|
||
| var code = root.TryGetProperty("code", out var c) ? c.GetString() : null; | ||
|
|
||
| if (code != "00000") | ||
| { | ||
| var msg = root.TryGetProperty("message", out var m) ? m.GetString() : "Unknown error"; | ||
| return new PaymentResponse | ||
| { | ||
| Success = false, | ||
| Message = $"OPay payment initiation failed: {msg}", | ||
| Status = PaymentStatus.Failed | ||
| }; | ||
| } | ||
|
|
||
| var data = root.GetProperty("data"); | ||
| var cashierUrl = data.TryGetProperty("cashierUrl", out var cu) ? cu.GetString() : string.Empty; | ||
|
|
||
| return new PaymentResponse | ||
| { | ||
| Success = true, | ||
| TransactionReference = txRef, | ||
| CheckoutUrl = cashierUrl ?? string.Empty, | ||
| Message = "Payment initiated successfully", | ||
| Status = PaymentStatus.Pending, | ||
| GatewayResponse = new Dictionary<string, string> | ||
| { | ||
| ["reference"] = txRef, | ||
| ["cashierUrl"] = cashierUrl ?? string.Empty | ||
| } | ||
| }; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error initiating OPay payment"); | ||
| throw new PaymentGatewayException("Failed to initiate OPay payment", ex); | ||
| } | ||
| } | ||
|
|
||
| public async Task<VerificationResponse> VerifyPaymentAsync(string transactionReference) | ||
| { | ||
| try | ||
| { | ||
| var payload = new | ||
| { | ||
| merchantId = _config.Opay.MerchantId, | ||
| reference = transactionReference | ||
| }; | ||
|
|
||
| var json = JsonSerializer.Serialize(payload); | ||
| var signature = GenerateSignature(json); | ||
|
|
||
| _httpClient.DefaultRequestHeaders.Clear(); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {_config.Opay.PublicKey}"); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("MerchantId", _config.Opay.MerchantId); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Signature", signature); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json"); | ||
|
|
||
| var content = new StringContent(json, Encoding.UTF8, "application/json"); | ||
| var response = await _httpClient.PostAsync($"{BaseUrl}/international/cashier/query", content); | ||
|
|
||
| var responseBody = await response.Content.ReadAsStringAsync(); | ||
| _logger.LogDebug("OPay verify response: {Body}", responseBody); | ||
|
|
||
| var doc = JsonDocument.Parse(responseBody); | ||
| var root = doc.RootElement; | ||
|
|
||
| var code = root.TryGetProperty("code", out var c) ? c.GetString() : null; | ||
| var data = code == "00000" && root.TryGetProperty("data", out var d) ? d : (JsonElement?)null; | ||
|
|
||
| // OPay statuses: SUCCESS, PENDING, FAIL, CLOSE | ||
| var status = data?.TryGetProperty("status", out var st) == true ? st.GetString() : null; | ||
| var amountKobo = data?.TryGetProperty("amount", out var am) == true ? am.GetInt64() : 0L; | ||
| var currency = data?.TryGetProperty("currency", out var cur) == true ? cur.GetString() : "NGN"; | ||
|
|
||
| var paymentStatus = status == "SUCCESS" ? PaymentStatus.Successful | ||
| : status == "PENDING" ? PaymentStatus.Pending | ||
| : PaymentStatus.Failed; | ||
|
|
||
| return new VerificationResponse | ||
| { | ||
| Success = status == "SUCCESS", | ||
| TransactionReference = transactionReference, | ||
| Amount = amountKobo / 100m, | ||
| Currency = currency ?? "NGN", | ||
| Status = paymentStatus, | ||
| Message = status == "SUCCESS" ? "Payment verified successfully" : $"Payment status: {status}", | ||
| GatewayResponse = new Dictionary<string, string> { ["status"] = status ?? string.Empty } | ||
| }; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error verifying OPay payment: {Ref}", transactionReference); | ||
| throw new PaymentGatewayException("Failed to verify OPay payment", ex); | ||
| } | ||
| } | ||
|
|
||
| public async Task<RefundResponse> RefundPaymentAsync(RefundRequest request) | ||
| { | ||
| try | ||
| { | ||
| var payload = new | ||
| { | ||
| merchantId = _config.Opay.MerchantId, | ||
| reference = request.TransactionReference, | ||
| amount = new { total = (long)(request.Amount * 100), currency = "NGN" }, | ||
| reason = request.Reason ?? "Customer request" | ||
| }; | ||
|
|
||
| var json = JsonSerializer.Serialize(payload); | ||
| var signature = GenerateSignature(json); | ||
|
|
||
| _httpClient.DefaultRequestHeaders.Clear(); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {_config.Opay.PublicKey}"); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("MerchantId", _config.Opay.MerchantId); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Signature", signature); | ||
| _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json"); | ||
|
|
||
| var content = new StringContent(json, Encoding.UTF8, "application/json"); | ||
| var response = await _httpClient.PostAsync($"{BaseUrl}/international/cashier/refund", content); | ||
|
|
||
| var responseBody = await response.Content.ReadAsStringAsync(); | ||
| _logger.LogDebug("OPay refund response: {Body}", responseBody); | ||
|
|
||
| var doc = JsonDocument.Parse(responseBody); | ||
| var root = doc.RootElement; | ||
|
|
||
| var code = root.TryGetProperty("code", out var c) ? c.GetString() : null; | ||
| var isSuccess = code == "00000"; | ||
| var msg = root.TryGetProperty("message", out var m) ? m.GetString() : null; | ||
|
|
||
| return new RefundResponse | ||
| { | ||
| Success = isSuccess, | ||
| RefundReference = request.TransactionReference, | ||
| TransactionReference = request.TransactionReference, | ||
| Amount = request.Amount, | ||
| Message = isSuccess ? "Refund initiated successfully" : (msg ?? "Refund failed"), | ||
| Status = isSuccess ? PaymentStatus.Refunded : PaymentStatus.Failed | ||
| }; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error processing OPay refund: {Ref}", request.TransactionReference); | ||
| throw new PaymentGatewayException("Failed to process OPay refund", ex); | ||
| } | ||
| } | ||
|
Comment on lines
+196
to
+245
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: Hardcoded "NGN" currency in refunds breaks multi-currency support. Line 204 hardcodes 🐛 Proposed fixPass the original currency through the var payload = new
{
merchantId = _config.Opay.MerchantId,
reference = request.TransactionReference,
- amount = new { total = (long)(request.Amount * 100), currency = "NGN" },
+ amount = new { total = (long)(request.Amount * 100), currency = request.Currency ?? "NGN" },
reason = request.Reason ?? "Customer request"
};Ensure 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,7 @@ | |
| using PayBridge.SDK.Exceptions; | ||
| using PayBridge.SDK.Interfaces; | ||
|
|
||
| namespace PayBridge.SDK.Gateways; | ||
| namespace PayBridge.SDK; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Check namespace declarations for all gateway implementations.
# Find all gateway files and extract their namespaces
rg -n '^namespace ' --glob '*Gateway.cs' -A 0Repository: teesofttech/PayBridge Length of output: 1043 🏁 Script executed: #!/bin/bash
set -euo pipefail
# 1) Find any remaining gateway files using the old namespace
rg -n 'namespace\s+PayBridge\.SDK\.Gateways' --glob '*Gateway.cs' .
# 2) Check for any references to the old fully-qualified RemitaGateway name
rg -n 'PayBridge\.SDK\.Gateways\.RemitaGateway' .
# 3) Show where RemitaGateway is declared and what namespace it uses
rg -n 'class\s+RemitaGateway\b|interface\s+IRemitaGateway\b|record\s+RemitaGateway\b' --glob '*RemitaGateway*.cs' .Repository: teesofttech/PayBridge Length of output: 47 🏁 Script executed: #!/bin/bash
set +euo pipefail
echo "=== Old namespace declarations (PayBridge.SDK.Gateways) in *Gateway.cs ==="
rg -n 'namespace\s+PayBridge\.SDK\.Gateways' --glob '*Gateway.cs' . || true
echo
echo "=== References to old fully-qualified RemitaGateway (PayBridge.SDK.Gateways.RemitaGateway) ==="
rg -n 'PayBridge\.SDK\.Gateways\.RemitaGateway' . || true
echo
echo "=== RemitaGateway declarations (class/interface/record) ==="
rg -n 'class\s+RemitaGateway\b|interface\s+IRemitaGateway\b|record\s+RemitaGateway\b' --glob '*Remita*' . || true
echo
echo "=== RemitaGateway file content (namespace line + any relevant type signature) ==="
f="PayBridge.SDK/Gateways/RemitaGateway.cs"
if [ -f "$f" ]; then
rg -n 'namespace\s+|class\s+RemitaGateway\b|interface\s+IRemitaGateway\b|record\s+RemitaGateway\b' "$f" || true
else
echo "Missing $f"
fiRepository: teesofttech/PayBridge Length of output: 546 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "=== References to old namespace prefix PayBridge.SDK.Gateways. ==="
rg -n 'PayBridge\.SDK\.Gateways\.' .
echo
echo "=== Using directives for PayBridge.SDK.Gateways ==="
rg -n 'using\s+PayBridge\.SDK\.Gateways\b' .Repository: teesofttech/PayBridge Length of output: 132 Breaking change: RemitaGateway namespace moved to 🤖 Prompt for AI Agents |
||
|
|
||
| /// <summary> | ||
| /// Remita payment gateway implementation for Nigerian payments. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.Remita, PaymentGatewayType.Paystack, PaymentGatewayType.Flutterwave); | ||
| return ChooseAvailableGateway(PaymentGatewayType.Monnify, PaymentGatewayType.Squad, PaymentGatewayType.Korapay, PaymentGatewayType.Interswitch, PaymentGatewayType.Remita, PaymentGatewayType.Opay, PaymentGatewayType.Paystack, PaymentGatewayType.Flutterwave); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Find all NGN gateway selection logic to verify consistency.
# Search for NGN currency handling
rg -n 'case.*"NGN"' -A 3 -B 1Repository: teesofttech/PayBridge Length of output: 623 Align NGN gateway preference order with stated routing priority. In 🤖 Prompt for AI Agents |
||
|
|
||
| case "KES": | ||
| case "GHS": | ||
|
|
@@ -347,6 +347,10 @@ private PaymentGatewayType DetermineGatewayFromReference(string transactionRefer | |
| return PaymentGatewayType.Remita; | ||
| } | ||
|
|
||
| if (transactionReference.StartsWith("OP_")) | ||
| { | ||
| return PaymentGatewayType.Opay; | ||
| } | ||
| // Default to configured default gateway | ||
| _logger.LogWarning("Could not determine gateway from reference: {Reference}", transactionReference); | ||
| return _config.DefaultGateway; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Breaking change: RemitaConfig sandbox default flipped to production.
Changing
IsSandboxdefault fromtruetofalsemeans existing deployments that relied on the sandbox default will now hit Remita's production environment without explicit configuration. This could cause unintended live transactions or API errors if production credentials aren't configured.trueand require explicit opt-in to production via configuration.MerchantId,ServiceTypeId, andApiKeyare configured before allowing production mode.🤖 Prompt for AI Agents