From 49249a6c6cd6c22adfee9fbd2f2b15cac681b3e7 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 18 Jul 2026 10:19:11 +0100 Subject: [PATCH 1/3] Fix automatic gateway routing --- .../Unit/PaymentServiceRoutingTests.cs | 127 ++++++++++++++++++ PayBridge.SDK/Services/PaymentService.cs | 84 ++++++------ docs/automatic-gateway-routing.md | 25 ++++ 3 files changed, 194 insertions(+), 42 deletions(-) create mode 100644 PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs create mode 100644 docs/automatic-gateway-routing.md diff --git a/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs b/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs new file mode 100644 index 0000000..6b7ae39 --- /dev/null +++ b/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs @@ -0,0 +1,127 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using PayBridge.SDK.Application.Dtos; +using PayBridge.SDK.Dtos.Request; +using PayBridge.SDK.Dtos.Response; +using PayBridge.SDK.Enums; +using PayBridge.SDK.Interfaces; +using Xunit; + +namespace PayBridge.SDK.Test.Unit; + +[Trait("Category", "Unit")] +public class PaymentServiceRoutingTests +{ + [Fact] + public async Task Automatic_routing_honors_compatible_configured_default() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var checkout = Gateway(PaymentGatewayType.Checkout); + var service = CreateService( + new PaymentGatewayConfig { DefaultGateway = PaymentGatewayType.Checkout }, + stripe.Object, + checkout.Object); + + await service.CreatePaymentAsync(Request("USD")); + + checkout.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Once); + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Automatic_routing_is_deterministic_across_registration_order() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var checkout = Gateway(PaymentGatewayType.Checkout); + var service = CreateService(new PaymentGatewayConfig(), checkout.Object, stripe.Object); + + await service.CreatePaymentAsync(Request("USD")); + + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Once); + checkout.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Incompatible_default_is_skipped_for_compatible_gateway() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var paystack = Gateway(PaymentGatewayType.Paystack); + var service = CreateService( + new PaymentGatewayConfig { DefaultGateway = PaymentGatewayType.Paystack }, + paystack.Object, + stripe.Object); + + await service.CreatePaymentAsync(Request("USD")); + + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Once); + paystack.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Single_incompatible_gateway_does_not_bypass_routing_rules() + { + var checkout = Gateway(PaymentGatewayType.Checkout); + var service = CreateService(new PaymentGatewayConfig(), checkout.Object); + + var action = () => service.CreatePaymentAsync(Request("NGN")); + + await action.Should().ThrowAsync().WithMessage("*supports NGN*"); + checkout.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("ZZZ", PaymentMethodType.Card)] + [InlineData("USD", PaymentMethodType.Crypto)] + public async Task Unsupported_routes_fail_before_provider_call( + string currency, + PaymentMethodType method) + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var service = CreateService(new PaymentGatewayConfig(), stripe.Object); + var request = Request(currency); + request.PaymentMethodType = method; + + var action = () => service.CreatePaymentAsync(request); + + await action.Should().ThrowAsync().WithMessage("*supports*"); + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Stripe_routes_documented_JPY_currency() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var service = CreateService(new PaymentGatewayConfig(), stripe.Object); + + await service.CreatePaymentAsync(Request("JPY")); + + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Once); + } + + private static PaymentService CreateService( + PaymentGatewayConfig config, + params IPaymentGateway[] gateways) => + new( + Mock.Of(), + Mock.Of(), + gateways, + NullLogger.Instance, + config); + + private static Mock Gateway(PaymentGatewayType type) + { + var gateway = new Mock(); + gateway.SetupGet(item => item.GatewayType).Returns(type); + gateway.Setup(item => item.CreatePaymentAsync(It.IsAny())) + .ReturnsAsync(new PaymentResponse { Success = false, Status = PaymentStatus.Failed }); + return gateway; + } + + private static PaymentRequest Request(string currency) => new() + { + Amount = 100m, + Currency = currency, + CustomerEmail = "customer@example.test" + }; +} diff --git a/PayBridge.SDK/Services/PaymentService.cs b/PayBridge.SDK/Services/PaymentService.cs index ecf063d..0c6be56 100644 --- a/PayBridge.SDK/Services/PaymentService.cs +++ b/PayBridge.SDK/Services/PaymentService.cs @@ -435,12 +435,10 @@ public async Task RefundPaymentAsync(RefundRequest request) private PaymentGatewayType SelectBestGateway(PaymentRequest request) { - // Logic to determine best gateway based on various factors - - // If only one gateway is enabled, use that - if (_gateways.Count == 1) + if (request.PaymentMethodType == PaymentMethodType.Crypto) { - return _gateways.Keys.First(); + throw new PaymentGatewayException( + "No configured gateway supports payment method Crypto."); } // Check for saved payment method - must use the same gateway @@ -451,56 +449,58 @@ private PaymentGatewayType SelectBestGateway(PaymentRequest request) } // Select based on currency - switch (request.Currency?.ToUpper()) - { - case "NGN": - return ChooseAvailableGateway(PaymentGatewayType.Monnify, PaymentGatewayType.Squad, PaymentGatewayType.Korapay, PaymentGatewayType.Interswitch, PaymentGatewayType.Remita, PaymentGatewayType.Opay, PaymentGatewayType.Paystack, PaymentGatewayType.Flutterwave); - - case "KES": - case "GHS": - case "UGX": - case "TZS": - case "ZAR": - case "RWF": - case "ZMW": - case "CDF": - case "XOF": - case "XAF": - case "MWK": - 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); - - case "KWD": - return ChooseAvailableGateway(PaymentGatewayType.Knet); - - case "USD": - case "EUR": - case "GBP": - default: - return ChooseAvailableGateway(PaymentGatewayType.Stripe, PaymentGatewayType.Checkout); - } + var compatibleGateways = request.Currency.ToUpperInvariant() switch + { + "NGN" => new[] { PaymentGatewayType.Monnify, PaymentGatewayType.Squad, PaymentGatewayType.Korapay, PaymentGatewayType.Interswitch, PaymentGatewayType.Remita, PaymentGatewayType.Opay, PaymentGatewayType.Paystack, PaymentGatewayType.Flutterwave }, + "KES" or "GHS" or "UGX" or "TZS" or "ZAR" or "RWF" or "ZMW" or + "CDF" or "XOF" or "XAF" or "MWK" => + [PaymentGatewayType.PeachPayments, PaymentGatewayType.PawaPay, PaymentGatewayType.DpoGroup, PaymentGatewayType.Flutterwave, PaymentGatewayType.Paystack], + "BWP" => [PaymentGatewayType.PeachPayments, PaymentGatewayType.DpoGroup], + "BHD" => [PaymentGatewayType.BenefitPay], + "KWD" => [PaymentGatewayType.Knet], + "USD" or "EUR" or "GBP" => [PaymentGatewayType.Stripe, PaymentGatewayType.Checkout], + "JPY" => [PaymentGatewayType.Stripe], + _ => [] + }; + + return ChooseAvailableGateway(request, compatibleGateways); } #region [Private Methods] - private PaymentGatewayType ChooseAvailableGateway(params PaymentGatewayType[] preferredGateways) + private PaymentGatewayType ChooseAvailableGateway( + PaymentRequest request, + IReadOnlyCollection compatibleGateways) { + if (_config.DefaultGateway != PaymentGatewayType.Automatic && + compatibleGateways.Contains(_config.DefaultGateway) && + _gateways.ContainsKey(_config.DefaultGateway)) + { + _logger.LogInformation( + "Selected configured default gateway {Gateway} for {Currency} and {PaymentMethod}", + _config.DefaultGateway, + request.Currency, + request.PaymentMethodType); + return _config.DefaultGateway; + } + // Try each gateway in order of preference - foreach (var gateway in preferredGateways) + foreach (var gateway in compatibleGateways) { if (_gateways.ContainsKey(gateway)) { + _logger.LogInformation( + "Selected compatible gateway {Gateway} for {Currency} and {PaymentMethod}", + gateway, + request.Currency, + request.PaymentMethodType); return gateway; } } - // Fall back to the first available gateway - return _gateways.Keys.First(); + throw new PaymentGatewayException( + $"No configured gateway supports {request.Currency.ToUpperInvariant()} " + + $"with payment method {request.PaymentMethodType}."); } private PaymentGatewayType DetermineGatewayFromReference(string transactionReference) diff --git a/docs/automatic-gateway-routing.md b/docs/automatic-gateway-routing.md new file mode 100644 index 0000000..ab05807 --- /dev/null +++ b/docs/automatic-gateway-routing.md @@ -0,0 +1,25 @@ +# Automatic gateway routing + +Automatic routing considers only configured gateways in the currency's declared +compatibility list. A configured `DefaultGateway` is selected when it is both +registered and compatible; otherwise PayBridge uses the deterministic order for +that currency. DI registration order does not affect the result. + +PayBridge rejects unknown currencies, unsupported payment methods, and cases +where no compatible gateway is configured before making a provider request. It +does not fall back to an arbitrary registered gateway. + +Current currency groups are: + +- NGN: Monnify, Squad, Korapay, Interswitch, Remita, OPay, Paystack, Flutterwave +- KES/GHS/UGX/TZS/ZAR/RWF/ZMW/CDF/XOF/XAF/MWK: Peach Payments, pawaPay, DPO, + Flutterwave, Paystack +- BWP: Peach Payments, DPO +- BHD: BenefitPay +- KWD: KNET +- USD/EUR/GBP: Stripe, Checkout.com +- JPY: Stripe + +Cryptocurrency is not implemented by any current PayBridge gateway and is +rejected explicitly. Provider capability metadata for finer payment-method and +regional routing remains tracked by issue #90. From 869d1db9be7f0e85b9c328c9f883ea3a5f730d04 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 18 Jul 2026 10:43:12 +0100 Subject: [PATCH 2/3] Harden gateway startup validation and automatic routing safety --- .../Unit/IServiceCollectionExtensionsTests.cs | 71 ++++++++++++++++++- .../Unit/PaymentServiceRoutingTests.cs | 49 ++++++++++++- .../IServiceCollectionExtensions.cs | 46 +++++++++++- PayBridge.SDK/Services/PaymentService.cs | 18 +++-- docs/automatic-gateway-routing.md | 4 ++ 5 files changed, 179 insertions(+), 9 deletions(-) diff --git a/PayBridge.SDK.Test/Unit/IServiceCollectionExtensionsTests.cs b/PayBridge.SDK.Test/Unit/IServiceCollectionExtensionsTests.cs index 58e7486..7205267 100644 --- a/PayBridge.SDK.Test/Unit/IServiceCollectionExtensionsTests.cs +++ b/PayBridge.SDK.Test/Unit/IServiceCollectionExtensionsTests.cs @@ -17,7 +17,7 @@ public void AddPayBridge_BindsConfiguration_AndAppliesCodeOverrides_ToSingletonA { var configuration = BuildConfiguration(new Dictionary { - ["PaymentGatewayConfig:DefaultGateway"] = "Stripe", + ["PaymentGatewayConfig:DefaultGateway"] = "Paystack", ["PaymentGatewayConfig:EnabledGateways:0"] = "Paystack", ["PaymentGatewayConfig:Paystack:SecretKey"] = "sk_from_config" }); @@ -35,7 +35,7 @@ public void AddPayBridge_BindsConfiguration_AndAppliesCodeOverrides_ToSingletonA var config = provider.GetRequiredService(); var options = provider.GetRequiredService>(); - config.DefaultGateway.Should().Be(PaymentGatewayType.Stripe); + config.DefaultGateway.Should().Be(PaymentGatewayType.Paystack); config.EnabledGateways.Should().ContainSingle().Which.Should().Be(PaymentGatewayType.Paystack); config.Paystack.SecretKey.Should().Be("sk_from_code"); options.Value.Should().BeSameAs(config); @@ -103,6 +103,73 @@ public void AddPayBridge_RegistersWebhookVerifier_AndPreservesCustomTimeProvider provider.GetRequiredService().Should().BeSameAs(customTimeProvider); } + [Fact] + public void AddPayBridge_WhenEnabledGatewayIsMissingRequiredConfig_Throws() + { + var configuration = BuildConfiguration(new Dictionary + { + ["PaymentGatewayConfig:EnabledGateways:0"] = "Paystack" + }); + var services = new ServiceCollection(); + services.AddLogging(); + + var action = () => services.AddPayBridge(configuration); + + action.Should().Throw() + .WithMessage("*Paystack*missing required configuration*"); + } + + [Fact] + public void AddPayBridge_WhenEnabledGatewaysContainsAutomatic_Throws() + { + var configuration = BuildConfiguration(new Dictionary + { + ["PaymentGatewayConfig:EnabledGateways:0"] = "Automatic" + }); + var services = new ServiceCollection(); + services.AddLogging(); + + var action = () => services.AddPayBridge(configuration); + + action.Should().Throw() + .WithMessage("*cannot include Automatic*"); + } + + [Fact] + public void AddPayBridge_WhenDefaultGatewayIsMissingRequiredConfig_Throws() + { + var configuration = BuildConfiguration(new Dictionary + { + ["PaymentGatewayConfig:DefaultGateway"] = "Paystack" + }); + var services = new ServiceCollection(); + services.AddLogging(); + + var action = () => services.AddPayBridge(configuration); + + action.Should().Throw() + .WithMessage("*Default gateway*Paystack*missing required configuration*"); + } + + [Fact] + public void AddPayBridge_WhenDefaultGatewayIsNotInEnabledGateways_Throws() + { + var configuration = BuildConfiguration(new Dictionary + { + ["PaymentGatewayConfig:DefaultGateway"] = "Stripe", + ["PaymentGatewayConfig:EnabledGateways:0"] = "Paystack", + ["PaymentGatewayConfig:Stripe:SecretKey"] = "sk_test_stripe", + ["PaymentGatewayConfig:Paystack:SecretKey"] = "sk_test_paystack" + }); + var services = new ServiceCollection(); + services.AddLogging(); + + var action = () => services.AddPayBridge(configuration); + + action.Should().Throw() + .WithMessage("*Default gateway*Stripe*must be included in EnabledGateways*"); + } + [Fact] public void AddPayBridge_RegistersRefundRepository() { diff --git a/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs b/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs index 6b7ae39..5564714 100644 --- a/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs +++ b/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs @@ -73,6 +73,11 @@ public async Task Single_incompatible_gateway_does_not_bypass_routing_rules() [Theory] [InlineData("ZZZ", PaymentMethodType.Card)] [InlineData("USD", PaymentMethodType.Crypto)] + [InlineData("NGN", PaymentMethodType.BankTransfer)] + [InlineData("KES", PaymentMethodType.MobileMoney)] + [InlineData("USD", PaymentMethodType.Wallet)] + [InlineData("NGN", PaymentMethodType.Ussd)] + [InlineData("NGN", PaymentMethodType.QrCode)] public async Task Unsupported_routes_fail_before_provider_call( string currency, PaymentMethodType method) @@ -84,7 +89,11 @@ public async Task Unsupported_routes_fail_before_provider_call( var action = () => service.CreatePaymentAsync(request); - await action.Should().ThrowAsync().WithMessage("*supports*"); + var exception = await action.Should().ThrowAsync(); + var message = exception.Which.Message; + (message.Contains("supports", StringComparison.OrdinalIgnoreCase) || + message.Contains("Specify a gateway explicitly", StringComparison.OrdinalIgnoreCase)) + .Should().BeTrue(); stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); } @@ -99,6 +108,42 @@ public async Task Stripe_routes_documented_JPY_currency() stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Once); } + [Fact] + public async Task Automatic_routing_rejects_saved_payment_method_until_binding_is_implemented() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var service = CreateService(new PaymentGatewayConfig(), stripe.Object); + var request = Request("USD"); + request.SavedPaymentMethodId = "pm_saved_123"; + + var action = () => service.CreatePaymentAsync(request); + + await action.Should().ThrowAsync() + .WithMessage("*Saved payment method routing is not yet implemented*"); + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Verify_automatic_routing_rejects_unknown_reference_prefix() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var transactionRepository = new Mock(); + transactionRepository.Setup(repository => repository.GetByReferenceAsync("UNKNOWN_123")) + .ReturnsAsync((PayBridge.SDK.Entities.PaymentTransaction?)null); + var service = new PaymentService( + transactionRepository.Object, + Mock.Of(), + [stripe.Object], + NullLogger.Instance, + new PaymentGatewayConfig { DefaultGateway = PaymentGatewayType.Stripe }); + + var action = () => service.VerifyPaymentAsync("UNKNOWN_123"); + + await action.Should().ThrowAsync() + .WithMessage("*Unable to determine gateway from transaction reference*"); + stripe.Verify(item => item.VerifyPaymentAsync(It.IsAny()), Times.Never); + } + private static PaymentService CreateService( PaymentGatewayConfig config, params IPaymentGateway[] gateways) => @@ -115,6 +160,8 @@ private static Mock Gateway(PaymentGatewayType type) gateway.SetupGet(item => item.GatewayType).Returns(type); gateway.Setup(item => item.CreatePaymentAsync(It.IsAny())) .ReturnsAsync(new PaymentResponse { Success = false, Status = PaymentStatus.Failed }); + gateway.Setup(item => item.VerifyPaymentAsync(It.IsAny())) + .ReturnsAsync(new VerificationResponse { Success = false, Status = PaymentStatus.Failed }); return gateway; } diff --git a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs index affcce7..3227362 100644 --- a/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs +++ b/PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs @@ -1,10 +1,11 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using PayBridge.SDK.Application.Dtos; using PayBridge.SDK.Enums; +using PayBridge.SDK.Exceptions; using PayBridge.SDK.Interfaces; using PayBridge.SDK.Services; @@ -125,6 +126,8 @@ private static IServiceCollection AddPayBridgeCore( configuration?.GetSection("PaymentGatewayConfig").Bind(config); configAction?.Invoke(config); + ValidateDefaultGatewayConfiguration(config); + services.AddSingleton(config); services.AddSingleton>(Options.Create(config)); @@ -164,6 +167,8 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway return; } + ValidateEnabledGatewayConfiguration(config); + // Register only the enabled gateways foreach (var gateway in config.EnabledGateways) { @@ -171,6 +176,45 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway } } + private static void ValidateEnabledGatewayConfiguration(PaymentGatewayConfig config) + { + foreach (var gateway in config.EnabledGateways) + { + if (gateway == PaymentGatewayType.Automatic) + { + throw new PaymentConfigurationException( + "PaymentGatewayConfig:EnabledGateways cannot include Automatic. " + + "Use concrete gateway types only."); + } + + if (!IsGatewayConfigured(config, gateway)) + { + throw new PaymentConfigurationException( + $"Enabled gateway '{gateway}' is missing required configuration values."); + } + } + } + + private static void ValidateDefaultGatewayConfiguration(PaymentGatewayConfig config) + { + if (config.DefaultGateway == PaymentGatewayType.Automatic) + { + return; + } + + if (!IsGatewayConfigured(config, config.DefaultGateway)) + { + throw new PaymentConfigurationException( + $"Default gateway '{config.DefaultGateway}' is missing required configuration values."); + } + + if (config.EnabledGateways.Count > 0 && !config.EnabledGateways.Contains(config.DefaultGateway)) + { + throw new PaymentConfigurationException( + $"Default gateway '{config.DefaultGateway}' must be included in EnabledGateways when explicit gateway registration is used."); + } + } + private static void RegisterGateway(IServiceCollection services, PaymentGatewayType gateway) { switch (gateway) diff --git a/PayBridge.SDK/Services/PaymentService.cs b/PayBridge.SDK/Services/PaymentService.cs index 0c6be56..2be7ff0 100644 --- a/PayBridge.SDK/Services/PaymentService.cs +++ b/PayBridge.SDK/Services/PaymentService.cs @@ -441,11 +441,19 @@ private PaymentGatewayType SelectBestGateway(PaymentRequest request) "No configured gateway supports payment method Crypto."); } + if (request.PaymentMethodType != PaymentMethodType.Card) + { + throw new PaymentGatewayException( + $"Automatic routing for payment method {request.PaymentMethodType} " + + "is not yet implemented. Specify a gateway explicitly."); + } + // Check for saved payment method - must use the same gateway if (!string.IsNullOrEmpty(request.SavedPaymentMethodId)) { - // TODO: Lookup saved payment method and return its gateway - // For now, fall through to other selection logic + throw new PaymentGatewayException( + "Saved payment method routing is not yet implemented. " + + "Specify a gateway explicitly until provider binding is available."); } // Select based on currency @@ -580,9 +588,9 @@ private PaymentGatewayType DetermineGatewayFromReference(string transactionRefer { return PaymentGatewayType.PeachPayments; } - // Default to configured default gateway - _logger.LogWarning("Could not determine gateway from reference: {Reference}", transactionReference); - return _config.DefaultGateway; + throw new PaymentGatewayException( + $"Unable to determine gateway from transaction reference '{transactionReference}'. " + + "Specify a gateway explicitly for verification."); } private void ValidateRequest(PaymentRequest request) diff --git a/docs/automatic-gateway-routing.md b/docs/automatic-gateway-routing.md index ab05807..7f240de 100644 --- a/docs/automatic-gateway-routing.md +++ b/docs/automatic-gateway-routing.md @@ -5,6 +5,10 @@ compatibility list. A configured `DefaultGateway` is selected when it is both registered and compatible; otherwise PayBridge uses the deterministic order for that currency. DI registration order does not affect the result. +Automatic routing currently supports `PaymentMethodType.Card` only. For +`BankTransfer`, `MobileMoney`, `Wallet`, `Ussd`, and `QrCode`, specify a +concrete gateway explicitly in the payment request. + PayBridge rejects unknown currencies, unsupported payment methods, and cases where no compatible gateway is configured before making a provider request. It does not fall back to an arbitrary registered gateway. From 0aaa1ba94241c36d1a2e30e820659a16fd46afa5 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 18 Jul 2026 13:24:08 +0100 Subject: [PATCH 3/3] Address PR routing review feedback --- .../Unit/PaymentServiceRoutingTests.cs | 52 +++++++++++++++++++ PayBridge.SDK/Services/PaymentService.cs | 21 +++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs b/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs index 5564714..2358919 100644 --- a/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs +++ b/PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs @@ -58,6 +58,19 @@ public async Task Incompatible_default_is_skipped_for_compatible_gateway() paystack.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task Unregistered_compatible_default_is_skipped() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var service = CreateService( + new PaymentGatewayConfig { DefaultGateway = PaymentGatewayType.Checkout }, + stripe.Object); + + await service.CreatePaymentAsync(Request("USD")); + + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Once); + } + [Fact] public async Task Single_incompatible_gateway_does_not_bypass_routing_rules() { @@ -108,6 +121,45 @@ public async Task Stripe_routes_documented_JPY_currency() stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Once); } + [Fact] + public async Task Automatic_routing_trims_currency_before_matching() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var service = CreateService(new PaymentGatewayConfig(), stripe.Object); + + await service.CreatePaymentAsync(Request(" usd ")); + + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Explicit_gateway_rejects_crypto_before_provider_call() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var service = CreateService(new PaymentGatewayConfig(), stripe.Object); + var request = Request("USD"); + request.PaymentMethodType = PaymentMethodType.Crypto; + + var action = () => service.CreatePaymentAsync(request, PaymentGatewayType.Stripe); + + await action.Should().ThrowAsync() + .WithMessage("*payment method Crypto*"); + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Unsupported_currency_message_uses_trimmed_normalized_currency() + { + var stripe = Gateway(PaymentGatewayType.Stripe); + var service = CreateService(new PaymentGatewayConfig(), stripe.Object); + + var action = () => service.CreatePaymentAsync(Request(" zzz ")); + + await action.Should().ThrowAsync() + .WithMessage("*supports ZZZ*"); + stripe.Verify(item => item.CreatePaymentAsync(It.IsAny()), Times.Never); + } + [Fact] public async Task Automatic_routing_rejects_saved_payment_method_until_binding_is_implemented() { diff --git a/PayBridge.SDK/Services/PaymentService.cs b/PayBridge.SDK/Services/PaymentService.cs index 2be7ff0..b58a03b 100644 --- a/PayBridge.SDK/Services/PaymentService.cs +++ b/PayBridge.SDK/Services/PaymentService.cs @@ -45,6 +45,13 @@ public PaymentService( public async Task CreatePaymentAsync(PaymentRequest request, PaymentGatewayType gateway = PaymentGatewayType.Automatic) { ValidateRequest(request); + request.Currency = NormalizeCurrency(request.Currency); + + if (request.PaymentMethodType == PaymentMethodType.Crypto) + { + throw new PaymentGatewayException( + "No configured gateway supports payment method Crypto."); + } if (_gateways.Count == 0) { @@ -435,12 +442,6 @@ public async Task RefundPaymentAsync(RefundRequest request) private PaymentGatewayType SelectBestGateway(PaymentRequest request) { - if (request.PaymentMethodType == PaymentMethodType.Crypto) - { - throw new PaymentGatewayException( - "No configured gateway supports payment method Crypto."); - } - if (request.PaymentMethodType != PaymentMethodType.Card) { throw new PaymentGatewayException( @@ -457,7 +458,8 @@ private PaymentGatewayType SelectBestGateway(PaymentRequest request) } // Select based on currency - var compatibleGateways = request.Currency.ToUpperInvariant() switch + var currency = NormalizeCurrency(request.Currency); + var compatibleGateways = currency switch { "NGN" => new[] { PaymentGatewayType.Monnify, PaymentGatewayType.Squad, PaymentGatewayType.Korapay, PaymentGatewayType.Interswitch, PaymentGatewayType.Remita, PaymentGatewayType.Opay, PaymentGatewayType.Paystack, PaymentGatewayType.Flutterwave }, "KES" or "GHS" or "UGX" or "TZS" or "ZAR" or "RWF" or "ZMW" or @@ -507,10 +509,13 @@ private PaymentGatewayType ChooseAvailableGateway( } throw new PaymentGatewayException( - $"No configured gateway supports {request.Currency.ToUpperInvariant()} " + + $"No configured gateway supports {NormalizeCurrency(request.Currency)} " + $"with payment method {request.PaymentMethodType}."); } + private static string NormalizeCurrency(string currency) => + currency.Trim().ToUpperInvariant(); + private PaymentGatewayType DetermineGatewayFromReference(string transactionReference) { // Extract gateway from transaction reference prefix