diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
new file mode 100644
index 0000000..73fb8c3
--- /dev/null
+++ b/.github/workflows/ci-tests.yml
@@ -0,0 +1,71 @@
+name: CI - Unit Tests
+
+on:
+ push:
+ branches:
+ - master
+ - 'feature/**'
+ - 'fix/**'
+ pull_request:
+ branches:
+ - master
+
+jobs:
+ unit-tests:
+ name: Unit Tests (.NET 8)
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read # checkout
+ checks: write # dorny/test-reporter — create check runs
+ pull-requests: write # dorny/test-reporter — annotate PRs
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore dependencies
+ run: dotnet restore PayBridge.SDK.sln
+
+ - name: Build solution
+ run: dotnet build PayBridge.SDK.sln --configuration Release --no-restore
+
+ - name: Run unit tests
+ run: |
+ dotnet test PayBridge.SDK.Test/PayBridge.SDK.Test.csproj \
+ --configuration Release \
+ --no-build \
+ --filter "Category=Unit" \
+ --logger "trx;LogFileName=unit-test-results.trx" \
+ --results-directory ./TestResults \
+ --collect:"XPlat Code Coverage"
+
+ - name: Upload test results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: unit-test-results
+ path: ./TestResults/*.trx
+
+ - name: Upload coverage report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: coverage-report
+ path: ./TestResults/**/coverage.cobertura.xml
+
+ - name: Publish test results (PR comment)
+ uses: dorny/test-reporter@v1
+ if: always()
+ with:
+ name: Unit Test Results
+ path: ./TestResults/*.trx
+ reporter: dotnet-trx
+ fail-on-error: true
+ fail-on-empty: false
+
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
new file mode 100644
index 0000000..fea9821
--- /dev/null
+++ b/.github/workflows/integration-tests.yml
@@ -0,0 +1,96 @@
+name: Integration Tests
+
+on:
+ # Run manually from the Actions tab
+ workflow_dispatch:
+ inputs:
+ gateway:
+ description: 'Gateway to test (e.g. paystack, flutterwave, all)'
+ required: false
+ default: 'all'
+
+ # Also run nightly on master so regressions are caught early
+ schedule:
+ - cron: '0 2 * * *' # 02:00 UTC every day
+
+jobs:
+ integration-tests:
+ name: Integration Tests (.NET 8)
+ runs-on: ubuntu-latest
+ environment: integration # Create this environment in repo Settings > Environments
+
+ permissions:
+ contents: read
+ checks: write
+ pull-requests: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore dependencies
+ run: dotnet restore PayBridge.SDK.sln
+
+ - name: Build solution
+ run: dotnet build PayBridge.SDK.sln --configuration Release --no-restore
+
+ - name: Run integration tests
+ env:
+ # ── Nigerian gateways ────────────────────────────────────────────────
+ PAYSTACK_SECRET_KEY: ${{ secrets.PAYSTACK_SECRET_KEY }}
+ FLUTTERWAVE_SECRET_KEY: ${{ secrets.FLUTTERWAVE_SECRET_KEY }}
+ MONNIFY_API_KEY: ${{ secrets.MONNIFY_API_KEY }}
+ MONNIFY_SECRET_KEY: ${{ secrets.MONNIFY_SECRET_KEY }}
+ MONNIFY_CONTRACT_CODE: ${{ secrets.MONNIFY_CONTRACT_CODE }}
+ SQUAD_SECRET_KEY: ${{ secrets.SQUAD_SECRET_KEY }}
+ SQUAD_IS_SANDBOX: 'true'
+ KORAPAY_SECRET_KEY: ${{ secrets.KORAPAY_SECRET_KEY }}
+ INTERSWITCH_CLIENT_ID: ${{ secrets.INTERSWITCH_CLIENT_ID }}
+ INTERSWITCH_CLIENT_SECRET: ${{ secrets.INTERSWITCH_CLIENT_SECRET }}
+ REMITA_MERCHANT_ID: ${{ secrets.REMITA_MERCHANT_ID }}
+ REMITA_API_KEY: ${{ secrets.REMITA_API_KEY }}
+ REMITA_SERVICE_TYPE_ID: ${{ secrets.REMITA_SERVICE_TYPE_ID }}
+ OPAY_MERCHANT_ID: ${{ secrets.OPAY_MERCHANT_ID }}
+ OPAY_PUBLIC_KEY: ${{ secrets.OPAY_PUBLIC_KEY }}
+ OPAY_PRIVATE_KEY: ${{ secrets.OPAY_PRIVATE_KEY }}
+ # ── African / global gateways ────────────────────────────────────────
+ DPO_COMPANY_TOKEN: ${{ secrets.DPO_COMPANY_TOKEN }}
+ DPO_SERVICE_TYPE: ${{ secrets.DPO_SERVICE_TYPE }}
+ PAWAPAY_API_TOKEN: ${{ secrets.PAWAPAY_API_TOKEN }}
+ PEACH_ENTITY_ID: ${{ secrets.PEACH_ENTITY_ID }}
+ PEACH_ACCESS_TOKEN: ${{ secrets.PEACH_ACCESS_TOKEN }}
+ # ── Global gateways ──────────────────────────────────────────────────
+ STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
+ CHECKOUT_SECRET_KEY: ${{ secrets.CHECKOUT_SECRET_KEY }}
+ BENEFITPAY_MERCHANT_ID: ${{ secrets.BENEFITPAY_MERCHANT_ID }}
+ BENEFITPAY_API_KEY: ${{ secrets.BENEFITPAY_API_KEY }}
+ KNET_TRANSPORT_ID: ${{ secrets.KNET_TRANSPORT_ID }}
+ KNET_PASSWORD: ${{ secrets.KNET_PASSWORD }}
+ run: |
+ dotnet test PayBridge.SDK.Test/PayBridge.SDK.Test.csproj \
+ --configuration Release \
+ --no-build \
+ --filter "Category=Integration" \
+ --logger "trx;LogFileName=integration-test-results.trx" \
+ --results-directory ./TestResults
+
+ - name: Upload integration test results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: integration-test-results
+ path: ./TestResults/*.trx
+
+ - name: Publish test results
+ uses: dorny/test-reporter@v1
+ if: always()
+ with:
+ name: Integration Test Results
+ path: ./TestResults/*.trx
+ reporter: dotnet-trx
+ fail-on-error: false # don't fail the workflow if sandbox keys are missing
diff --git a/PayBridge.SDK.Test/Helpers/IntegrationTestBase.cs b/PayBridge.SDK.Test/Helpers/IntegrationTestBase.cs
new file mode 100644
index 0000000..734c402
--- /dev/null
+++ b/PayBridge.SDK.Test/Helpers/IntegrationTestBase.cs
@@ -0,0 +1,71 @@
+using Xunit;
+
+namespace PayBridge.SDK.Test.Helpers;
+
+///
+/// Base class for integration tests.
+/// Any test class that derives from this will automatically be skipped
+/// when required environment variables are missing, keeping CI output clean.
+///
+/// Usage:
+///
+/// public class PaystackIntegrationTests : IntegrationTestBase
+/// {
+/// public PaystackIntegrationTests()
+/// : base("PAYSTACK_SECRET_KEY") { }
+/// }
+///
+///
+public abstract class IntegrationTestBase
+{
+ private readonly string[] _requiredVars;
+
+ ///
+ /// The reason string reported by xUnit when a test is skipped.
+ /// Set in the constructor if any env var is missing.
+ ///
+ protected string? SkipReason { get; }
+
+ ///
+ /// Whether all required env vars are present.
+ /// Use this in test bodies to conditionally Skip:
+ /// Skip.If(ShouldSkip, SkipReason);
+ ///
+ protected bool ShouldSkip => SkipReason != null;
+
+ protected IntegrationTestBase(params string[] requiredEnvVars)
+ {
+ _requiredVars = requiredEnvVars;
+
+ var missing = requiredEnvVars
+ .Where(v => string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(v)))
+ .ToList();
+
+ if (missing.Count > 0)
+ {
+ SkipReason = $"Integration test skipped — missing env var(s): {string.Join(", ", missing)}";
+ }
+ }
+
+ ///
+ /// Reads a required env var. Throws if missing (only call after checking ShouldSkip).
+ ///
+ protected string GetRequiredEnv(string name)
+ {
+ var value = Environment.GetEnvironmentVariable(name);
+ if (string.IsNullOrWhiteSpace(value))
+ throw new InvalidOperationException(
+ $"Required env var '{name}' is not set. Did you check ShouldSkip first?");
+ return value;
+ }
+
+ ///
+ /// Skips the current test if env vars are missing.
+ /// Call this at the top of every integration test method.
+ ///
+ protected void SkipIfMissingEnvVars()
+ {
+ if (ShouldSkip)
+ Skip.If(true, SkipReason!);
+ }
+}
diff --git a/PayBridge.SDK.Test/Helpers/MockHttpMessageHandler.cs b/PayBridge.SDK.Test/Helpers/MockHttpMessageHandler.cs
new file mode 100644
index 0000000..fa4b8e0
--- /dev/null
+++ b/PayBridge.SDK.Test/Helpers/MockHttpMessageHandler.cs
@@ -0,0 +1,115 @@
+using System.Net;
+using System.Text;
+
+namespace PayBridge.SDK.Test.Helpers;
+
+///
+/// A fake HttpMessageHandler that returns pre-queued responses without hitting the network.
+/// Supports multi-step gateways (e.g. Monnify/Interswitch that do OAuth then pay).
+///
+public sealed class MockHttpMessageHandler : HttpMessageHandler
+{
+ private readonly Queue _queue = new();
+ private readonly List _requests = new();
+
+ /// All requests made through this handler, in order.
+ public IReadOnlyList Requests => _requests.AsReadOnly();
+
+ /// The most recent request made through this handler.
+ public HttpRequestMessage? LastRequest => _requests.Count > 0 ? _requests[^1] : null;
+
+ // ── Fluent setup ──────────────────────────────────────────────────────────
+
+ /// Queue a single JSON response.
+ public MockHttpMessageHandler RespondWith(
+ HttpStatusCode statusCode,
+ string jsonBody,
+ string contentType = "application/json")
+ {
+ _queue.Enqueue(new MockHttpResponse(statusCode, jsonBody, contentType));
+ return this;
+ }
+
+ /// Queue a plain-text / XML response.
+ public MockHttpMessageHandler RespondWithText(
+ HttpStatusCode statusCode,
+ string body,
+ string contentType = "text/plain")
+ {
+ _queue.Enqueue(new MockHttpResponse(statusCode, body, contentType));
+ return this;
+ }
+
+ /// Queue an empty 200 OK response.
+ public MockHttpMessageHandler RespondWithOk()
+ => RespondWith(HttpStatusCode.OK, "{}");
+
+ // ── Core override ─────────────────────────────────────────────────────────
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ _requests.Add(request);
+
+ if (_queue.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"MockHttpMessageHandler: no queued response for {request.Method} {request.RequestUri}. " +
+ "Did you forget to call RespondWith()?");
+ }
+
+ var mock = _queue.Dequeue();
+ var response = new HttpResponseMessage(mock.StatusCode)
+ {
+ Content = new StringContent(mock.Body, Encoding.UTF8, mock.ContentType)
+ };
+
+ return Task.FromResult(response);
+ }
+
+ // ── Assertion helpers ─────────────────────────────────────────────────────
+
+ /// Assert that the handler received exactly requests.
+ public void AssertRequestCount(int count)
+ {
+ if (_requests.Count != count)
+ throw new InvalidOperationException(
+ $"Expected {count} HTTP request(s) but got {_requests.Count}.");
+ }
+
+ /// Assert the last request targeted the expected URL path.
+ public void AssertLastRequestPath(string expectedPathContains)
+ {
+ var path = LastRequest?.RequestUri?.ToString() ?? "(none)";
+ if (!path.Contains(expectedPathContains, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException(
+ $"Expected last request URL to contain '{expectedPathContains}' but was '{path}'.");
+ }
+
+ /// Assert the last request used the expected HTTP method.
+ public void AssertLastMethod(HttpMethod method)
+ {
+ if (LastRequest?.Method != method)
+ throw new InvalidOperationException(
+ $"Expected HTTP {method} but last request was {LastRequest?.Method}.");
+ }
+
+ ///
+ /// Build an wired to this handler.
+ /// Optionally sets a base address.
+ ///
+ public HttpClient BuildClient(string? baseAddress = null)
+ {
+ var client = new HttpClient(this);
+ if (baseAddress != null)
+ client.BaseAddress = new Uri(baseAddress);
+ return client;
+ }
+}
+
+/// A single pre-configured HTTP response in the queue.
+public sealed record MockHttpResponse(
+ HttpStatusCode StatusCode,
+ string Body,
+ string ContentType);
diff --git a/PayBridge.SDK.Test/Helpers/PaymentRequestFactory.cs b/PayBridge.SDK.Test/Helpers/PaymentRequestFactory.cs
new file mode 100644
index 0000000..b943dc8
--- /dev/null
+++ b/PayBridge.SDK.Test/Helpers/PaymentRequestFactory.cs
@@ -0,0 +1,185 @@
+using PayBridge.SDK.Application.Dtos;
+using PayBridge.SDK.Application.Dtos.Request;
+using PayBridge.SDK.Dtos.Request;
+using PayBridge.SDK.Enums;
+
+namespace PayBridge.SDK.Test.Helpers;
+
+///
+/// Builds valid PaymentRequest and RefundRequest objects with sensible test defaults.
+/// Override individual properties as needed in each test.
+///
+public static class PaymentRequestFactory
+{
+ public static PaymentRequest Build(Action? configure = null)
+ {
+ var request = new PaymentRequest
+ {
+ Amount = 5000.00m,
+ Currency = "NGN",
+ Description = "Test payment",
+ CustomerEmail = "test@paybridge.dev",
+ CustomerName = "Test User",
+ CustomerPhone = "+2348000000000",
+ RedirectUrl = "https://paybridge.dev/callback",
+ WebhookUrl = "https://paybridge.dev/webhook",
+ PaymentMethodType = PaymentMethodType.Card,
+ Metadata = new Dictionary
+ {
+ ["order_id"] = "ORD-001",
+ ["source"] = "unit-test"
+ }
+ };
+
+ configure?.Invoke(request);
+ return request;
+ }
+
+ public static RefundRequest BuildRefund(Action? configure = null)
+ {
+ var request = new RefundRequest
+ {
+ TransactionReference = "TEST_REF_001",
+ Amount = 5000.00m,
+ Reason = "Customer requested refund"
+ };
+
+ configure?.Invoke(request);
+ return request;
+ }
+}
+
+///
+/// Builds objects with a single gateway configured.
+/// Each method only sets the minimum keys needed to pass the gateway's constructor validation.
+///
+public static class GatewayConfigFactory
+{
+ public static PaymentGatewayConfig BuildPaystack(
+ string secretKey = "sk_test_paystack_secret") =>
+ new() { Paystack = new PaystackConfig { SecretKey = secretKey } };
+
+ public static PaymentGatewayConfig BuildFlutterwave(
+ string secretKey = "FLWSECK_TEST-flutterwave-secret") =>
+ new() { FlutterwaveConfig = new FlutterwaveConfig { SecretKey = secretKey } };
+
+ public static PaymentGatewayConfig BuildStripe(
+ string secretKey = "sk_test_stripe_secret") =>
+ new() { Stripe = new StripeConfig { SecretKey = secretKey } };
+
+ public static PaymentGatewayConfig BuildCheckout(
+ string secretKey = "sk_test_checkout_secret") =>
+ new() { Checkout = new CheckoutConfig { SecretKey = secretKey } };
+
+ public static PaymentGatewayConfig BuildBenefitPay(
+ string merchantId = "test_merchant",
+ string apiKey = "test_api_key") =>
+ new() { BenefitPay = new BenefitPayConfig { MerchantId = merchantId, ApiKey = apiKey } };
+
+ public static PaymentGatewayConfig BuildKnet(
+ string transportId = "test_transport",
+ string password = "test_password") =>
+ new() { Knet = new KnetConfig { TransportId = transportId, Password = password } };
+
+ public static PaymentGatewayConfig BuildMonnify(
+ string apiKey = "MK_TEST_monnify_api_key",
+ string secretKey = "test_monnify_secret",
+ string contractCode = "TEST_CONTRACT") =>
+ new()
+ {
+ Monnify = new MonnifyConfig
+ {
+ ApiKey = apiKey,
+ SecretKey = secretKey,
+ ContractCode = contractCode
+ }
+ };
+
+ public static PaymentGatewayConfig BuildSquad(
+ string secretKey = "test_squad_secret",
+ bool isSandbox = true) =>
+ new() { Squad = new SquadConfig { SecretKey = secretKey, IsSandbox = isSandbox } };
+
+ public static PaymentGatewayConfig BuildKorapay(
+ string secretKey = "sk_test_korapay_secret") =>
+ new() { Korapay = new KorapayConfig { SecretKey = secretKey } };
+
+ public static PaymentGatewayConfig BuildInterswitch(
+ string clientId = "test_client_id",
+ string clientSecret = "test_client_secret",
+ bool isSandbox = true) =>
+ new()
+ {
+ Interswitch = new InterswitchConfig
+ {
+ ClientId = clientId,
+ ClientSecret = clientSecret,
+ IsSandbox = isSandbox
+ }
+ };
+
+ public static PaymentGatewayConfig BuildRemita(
+ string merchantId = "test_merchant_id",
+ string serviceTypeId = "test_service_type",
+ string apiKey = "test_remita_api_key",
+ bool isSandbox = true) =>
+ new()
+ {
+ Remita = new RemitaConfig
+ {
+ MerchantId = merchantId,
+ ServiceTypeId = serviceTypeId,
+ ApiKey = apiKey,
+ IsSandbox = isSandbox
+ }
+ };
+
+ public static PaymentGatewayConfig BuildOpay(
+ string merchantId = "test_merchant_id",
+ string publicKey = "test_public_key",
+ string secretKey = "test_opay_secret",
+ bool isSandbox = true) =>
+ new()
+ {
+ Opay = new OpayConfig
+ {
+ MerchantId = merchantId,
+ PublicKey = publicKey,
+ SecretKey = secretKey,
+ IsSandbox = isSandbox
+ }
+ };
+
+ public static PaymentGatewayConfig BuildDpoGroup(
+ string companyToken = "TEST_DPO_COMPANY_TOKEN",
+ string paymentCurrency = "USD",
+ bool isSandbox = true) =>
+ new()
+ {
+ DpoGroup = new DpoGroupConfig
+ {
+ CompanyToken = companyToken,
+ PaymentCurrency = paymentCurrency,
+ IsSandbox = isSandbox
+ }
+ };
+
+ public static PaymentGatewayConfig BuildPawaPay(
+ string apiToken = "test_pawapay_token",
+ bool isSandbox = true) =>
+ new() { PawaPay = new PawaPayConfig { ApiToken = apiToken, IsSandbox = isSandbox } };
+
+ public static PaymentGatewayConfig BuildPeachPayments(
+ string entityId = "test_entity_id",
+ string accessToken = "test_access_token",
+ bool isSandbox = true) =>
+ new()
+ {
+ PeachPayments = new PeachPaymentsConfig
+ {
+ EntityId = entityId,
+ AccessToken = accessToken,
+ IsSandbox = isSandbox
+ }
+ };
+}
diff --git a/PayBridge.SDK.Test/PayBridge.SDK.Test.csproj b/PayBridge.SDK.Test/PayBridge.SDK.Test.csproj
index fa71b7a..0135f9d 100644
--- a/PayBridge.SDK.Test/PayBridge.SDK.Test.csproj
+++ b/PayBridge.SDK.Test/PayBridge.SDK.Test.csproj
@@ -1,9 +1,31 @@
-
+
net8.0
enable
enable
+ false
+ PayBridge.SDK.Test
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/PayBridge.SDK.Test/Unit/TestInfrastructureTests.cs b/PayBridge.SDK.Test/Unit/TestInfrastructureTests.cs
new file mode 100644
index 0000000..1c30976
--- /dev/null
+++ b/PayBridge.SDK.Test/Unit/TestInfrastructureTests.cs
@@ -0,0 +1,224 @@
+using System.Net;
+using FluentAssertions;
+using PayBridge.SDK.Test.Helpers;
+using Xunit;
+
+namespace PayBridge.SDK.Test.Unit;
+
+///
+/// Tests for the shared test infrastructure: MockHttpMessageHandler,
+/// PaymentRequestFactory, GatewayConfigFactory, and IntegrationTestBase.
+/// Closes #51.
+///
+[Trait("Category", "Unit")]
+public class TestInfrastructureTests
+{
+ // ── MockHttpMessageHandler ────────────────────────────────────────────────
+
+ [Fact]
+ public async Task MockHandler_ReturnsSingleQueuedResponse()
+ {
+ var handler = new MockHttpMessageHandler()
+ .RespondWith(HttpStatusCode.OK, """{"status":true}""");
+
+ var client = handler.BuildClient("https://api.example.com");
+
+ var response = await client.GetAsync("/test");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var body = await response.Content.ReadAsStringAsync();
+ body.Should().Contain("\"status\":true");
+ }
+
+ [Fact]
+ public async Task MockHandler_SupportsMultipleSequentialResponses()
+ {
+ var handler = new MockHttpMessageHandler()
+ .RespondWith(HttpStatusCode.OK, """{"access_token":"tok123"}""") // step 1 — auth
+ .RespondWith(HttpStatusCode.OK, """{"status":true,"url":"https://pay.test"}"""); // step 2 — pay
+
+ var client = handler.BuildClient("https://api.example.com");
+
+ var auth = await client.PostAsync("/auth", null);
+ var pay = await client.PostAsync("/pay", null);
+
+ handler.Requests.Should().HaveCount(2);
+ (await auth.Content.ReadAsStringAsync()).Should().Contain("access_token");
+ (await pay.Content.ReadAsStringAsync()).Should().Contain("url");
+ }
+
+ [Fact]
+ public async Task MockHandler_Throws_WhenQueueIsEmpty()
+ {
+ var handler = new MockHttpMessageHandler(); // no responses queued
+ var client = handler.BuildClient("https://api.example.com");
+
+ var act = async () => await client.GetAsync("/unexpected");
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*no queued response*");
+ }
+
+ [Fact]
+ public async Task MockHandler_RecordsRequests()
+ {
+ var handler = new MockHttpMessageHandler()
+ .RespondWith(HttpStatusCode.Created, """{"id":"abc"}""");
+
+ var client = handler.BuildClient("https://api.example.com");
+
+ await client.PostAsync("/payments", null);
+
+ handler.Requests.Should().HaveCount(1);
+ handler.LastRequest!.Method.Should().Be(HttpMethod.Post);
+ handler.LastRequest.RequestUri!.ToString().Should().Contain("/payments");
+ }
+
+ [Fact]
+ public async Task MockHandler_AssertRequestCount_Passes_ForCorrectCount()
+ {
+ var handler = new MockHttpMessageHandler()
+ .RespondWith(HttpStatusCode.OK, "{}")
+ .RespondWith(HttpStatusCode.OK, "{}");
+
+ var client = handler.BuildClient("https://api.example.com");
+
+ await client.GetAsync("/a");
+ await client.GetAsync("/b");
+
+ var act = () => handler.AssertRequestCount(2);
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public async Task MockHandler_AssertRequestCount_Fails_ForWrongCount()
+ {
+ var handler = new MockHttpMessageHandler()
+ .RespondWith(HttpStatusCode.OK, "{}");
+
+ var client = handler.BuildClient("https://api.example.com");
+ await client.GetAsync("/a");
+
+ var act = () => handler.AssertRequestCount(2);
+ act.Should().Throw()
+ .WithMessage("*Expected 2*got 1*");
+ }
+
+ [Fact]
+ public async Task MockHandler_AssertLastRequestPath_Passes_WhenPathMatches()
+ {
+ var handler = new MockHttpMessageHandler()
+ .RespondWith(HttpStatusCode.OK, "{}");
+
+ await handler.BuildClient("https://api.example.com").GetAsync("/payments/verify");
+
+ var act = () => handler.AssertLastRequestPath("/payments/verify");
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public async Task MockHandler_RespondWithText_SetsCorrectContentType()
+ {
+ var handler = new MockHttpMessageHandler()
+ .RespondWithText(HttpStatusCode.OK, "000", "text/xml");
+
+ var client = handler.BuildClient("https://api.example.com");
+ var response = await client.PostAsync("/xml", null);
+
+ response.Content.Headers.ContentType!.MediaType.Should().Be("text/xml");
+ (await response.Content.ReadAsStringAsync()).Should().Contain("000");
+ }
+
+ // ── PaymentRequestFactory ─────────────────────────────────────────────────
+
+ [Fact]
+ public void PaymentRequestFactory_Build_ReturnsValidRequest()
+ {
+ var request = PaymentRequestFactory.Build();
+
+ request.Amount.Should().BeGreaterThan(0);
+ request.Currency.Should().NotBeNullOrWhiteSpace();
+ request.CustomerEmail.Should().Contain("@");
+ request.RedirectUrl.Should().StartWith("https://");
+ request.Metadata.Should().NotBeEmpty();
+ }
+
+ [Fact]
+ public void PaymentRequestFactory_Build_AllowsCustomisation()
+ {
+ var request = PaymentRequestFactory.Build(r =>
+ {
+ r.Amount = 100m;
+ r.Currency = "USD";
+ });
+
+ request.Amount.Should().Be(100m);
+ request.Currency.Should().Be("USD");
+ request.CustomerEmail.Should().Contain("@"); // default still set
+ }
+
+ [Fact]
+ public void PaymentRequestFactory_BuildRefund_ReturnsValidRequest()
+ {
+ var refund = PaymentRequestFactory.BuildRefund();
+
+ refund.TransactionReference.Should().NotBeNullOrWhiteSpace();
+ refund.Amount.Should().BeGreaterThan(0);
+ refund.Reason.Should().NotBeNullOrWhiteSpace();
+ }
+
+ // ── GatewayConfigFactory ──────────────────────────────────────────────────
+
+ [Fact]
+ public void GatewayConfigFactory_BuildPaystack_SetsSecretKey()
+ {
+ var config = GatewayConfigFactory.BuildPaystack("sk_test_abc");
+ config.Paystack.SecretKey.Should().Be("sk_test_abc");
+ }
+
+ [Fact]
+ public void GatewayConfigFactory_BuildMonnify_SetsAllRequiredFields()
+ {
+ var config = GatewayConfigFactory.BuildMonnify("key", "secret", "contract");
+
+ config.Monnify.ApiKey.Should().Be("key");
+ config.Monnify.SecretKey.Should().Be("secret");
+ config.Monnify.ContractCode.Should().Be("contract");
+ }
+
+ [Fact]
+ public void GatewayConfigFactory_BuildSquad_DefaultsToSandbox()
+ {
+ var config = GatewayConfigFactory.BuildSquad();
+ config.Squad.IsSandbox.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GatewayConfigFactory_BuildInterswitch_SetsClientCredentials()
+ {
+ var config = GatewayConfigFactory.BuildInterswitch("id", "secret");
+
+ config.Interswitch.ClientId.Should().Be("id");
+ config.Interswitch.ClientSecret.Should().Be("secret");
+ }
+
+ [Fact]
+ public void GatewayConfigFactory_BuildPeachPayments_SetsEntityIdAndToken()
+ {
+ var config = GatewayConfigFactory.BuildPeachPayments("ent123", "tok456");
+
+ config.PeachPayments.EntityId.Should().Be("ent123");
+ config.PeachPayments.AccessToken.Should().Be("tok456");
+ }
+
+ [Theory]
+ [InlineData("NGN")]
+ [InlineData("USD")]
+ [InlineData("KES")]
+ [InlineData("GHS")]
+ public void PaymentRequestFactory_Build_SupportsDifferentCurrencies(string currency)
+ {
+ var request = PaymentRequestFactory.Build(r => r.Currency = currency);
+ request.Currency.Should().Be(currency);
+ }
+}
diff --git a/PayBridge.SDK.Test/paybridge.runsettings b/PayBridge.SDK.Test/paybridge.runsettings
new file mode 100644
index 0000000..7027e3d
--- /dev/null
+++ b/PayBridge.SDK.Test/paybridge.runsettings
@@ -0,0 +1,38 @@
+
+
+
+
+ 0
+ ./TestResults
+
+
+
+
+
+
+
+
+
+ .*PayBridge\.SDK\.dll
+
+
+ .*PayBridge\.SDK\.Test\.dll
+
+
+
+
+
+
+
+
diff --git a/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs b/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs
index babc536..5d432e2 100644
--- a/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs
+++ b/PayBridge.SDK/Dtos/PaymentGatewayConfig.cs
@@ -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
@@ -156,3 +157,21 @@ public class PawaPayConfig
///
public bool IsSandbox { get; set; } = false;
}
+
+public class PeachPaymentsConfig
+{
+ ///
+ /// Peach Payments Entity ID (from your Peach Payments dashboard).
+ ///
+ public string EntityId { get; set; } = string.Empty;
+
+ ///
+ /// Peach Payments Access Token (Bearer token).
+ ///
+ public string AccessToken { get; set; } = string.Empty;
+
+ ///
+ /// Set to true to use Peach Payments test/sandbox environment.
+ ///
+ public bool IsSandbox { get; set; } = false;
+}
diff --git a/PayBridge.SDK/Enums/PaymentGatewayType.cs b/PayBridge.SDK/Enums/PaymentGatewayType.cs
index a6b2d2e..0628798 100644
--- a/PayBridge.SDK/Enums/PaymentGatewayType.cs
+++ b/PayBridge.SDK/Enums/PaymentGatewayType.cs
@@ -77,5 +77,10 @@ public enum PaymentGatewayType
///
/// PawaPay payment gateway (Africa - mobile money)
///
- PawaPay = 14
+ PawaPay = 14,
+
+ ///
+ /// Peach Payments gateway (South Africa, Kenya, Nigeria)
+ ///
+ PeachPayments = 15
}
\ No newline at end of file
diff --git a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs
index da37a56..171e771 100644
--- a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs
+++ b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs
@@ -139,6 +139,7 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return;
}
@@ -190,6 +191,9 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
case PaymentGatewayType.PawaPay:
services.AddScoped();
break;
+ case PaymentGatewayType.PeachPayments:
+ services.AddScoped();
+ break;
}
}
}
diff --git a/PayBridge.SDK/Factories/PaymentGatewayFactory.cs b/PayBridge.SDK/Factories/PaymentGatewayFactory.cs
index f99e477..0a0be6a 100644
--- a/PayBridge.SDK/Factories/PaymentGatewayFactory.cs
+++ b/PayBridge.SDK/Factories/PaymentGatewayFactory.cs
@@ -49,6 +49,7 @@ public Dictionary CreateGateways()
TryAddGateway(gateways, PaymentGatewayType.Opay);
TryAddGateway(gateways, PaymentGatewayType.DpoGroup);
TryAddGateway(gateways, PaymentGatewayType.PawaPay);
+ TryAddGateway(gateways, PaymentGatewayType.PeachPayments);
}
else
{
@@ -96,6 +97,7 @@ private void TryAddGateway(Dictionary gatew
PaymentGatewayType.Opay => _serviceProvider.GetService(),
PaymentGatewayType.DpoGroup => _serviceProvider.GetService(),
PaymentGatewayType.PawaPay => _serviceProvider.GetService(),
+ PaymentGatewayType.PeachPayments => _serviceProvider.GetService(),
_ => null
};
diff --git a/PayBridge.SDK/Gateways/PeachPaymentsGateway.cs b/PayBridge.SDK/Gateways/PeachPaymentsGateway.cs
new file mode 100644
index 0000000..5c0f8de
--- /dev/null
+++ b/PayBridge.SDK/Gateways/PeachPaymentsGateway.cs
@@ -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;
+
+///
+/// Peach Payments gateway (South Africa, Kenya, Nigeria, Botswana).
+/// Currencies: ZAR, KES, NGN, BWP, USD.
+/// Auth: Bearer AccessToken + EntityId.
+/// Docs: https://developer.peachpayments.com
+///
+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 _logger;
+
+ public PeachPaymentsGateway(
+ PaymentGatewayConfig config,
+ IHttpClientFactory httpClientFactory,
+ ILogger 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 CreatePaymentAsync(PaymentRequest request)
+ {
+ try
+ {
+ var txRef = $"{TxRefPrefix}{Guid.NewGuid():N}";
+
+ var formData = new Dictionary
+ {
+ ["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
+ {
+ ["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 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;
+
+ 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
+ {
+ ["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 RefundPaymentAsync(RefundRequest request)
+ {
+ try
+ {
+ var formData = new Dictionary
+ {
+ ["entityId"] = _config.PeachPayments.EntityId,
+ ["amount"] = request.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
+ ["currency"] = "ZAR",
+ ["paymentType"] = "RF",
+ };
+
+ 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;
+ 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);
+ }
+ }
+
+ private static string CurrencyToCountry(string currency) => currency.ToUpperInvariant() switch
+ {
+ "ZAR" => "ZA",
+ "KES" => "KE",
+ "NGN" => "NG",
+ "BWP" => "BW",
+ _ => "ZA"
+ };
+}
diff --git a/PayBridge.SDK/Services/PaymentService.cs b/PayBridge.SDK/Services/PaymentService.cs
index 080c32f..46fc3ed 100644
--- a/PayBridge.SDK/Services/PaymentService.cs
+++ b/PayBridge.SDK/Services/PaymentService.cs
@@ -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);
+
+ case "BWP":
+ return ChooseAvailableGateway(PaymentGatewayType.PeachPayments, PaymentGatewayType.DpoGroup);
case "BHD":
return ChooseAvailableGateway(PaymentGatewayType.BenefitPay);
@@ -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;