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;