diff --git a/build.cake b/build.cake index 107ed953..ccb4b168 100644 --- a/build.cake +++ b/build.cake @@ -1,9 +1,9 @@ // Install .NET Core Global tools. -#tool "dotnet:?package=dotnet-reportgenerator-globaltool&version=5.4.1" +#tool "dotnet:?package=dotnet-reportgenerator-globaltool&version=5.4.9" #tool "dotnet:?package=coveralls.net&version=4.0.1" // Install addins -#addin nuget:?package=Cake.Coverlet&version=4.0.1 +#addin nuget:?package=Cake.Coverlet&version=5.1.1 /////////////////////////////////////////////////////////////////////////////// // ARGUMENTS diff --git a/src/TrueLayer/Payments/Model/CreatePaymentRequest.cs b/src/TrueLayer/Payments/Model/CreatePaymentRequest.cs index 8d4fe62b..89128728 100644 --- a/src/TrueLayer/Payments/Model/CreatePaymentRequest.cs +++ b/src/TrueLayer/Payments/Model/CreatePaymentRequest.cs @@ -24,6 +24,7 @@ public class CreatePaymentRequest /// If provided, the start authorization flow endpoint does not need to be called /// Add to the payment a list of custom key-value pairs as metadata /// The risk assessment and the payment_creditable webhook configuration. + /// Sub-merchants information for the payment public CreatePaymentRequest( long amountInMinor, string currency, @@ -32,7 +33,8 @@ public CreatePaymentRequest( RelatedProducts? relatedProducts = null, StartAuthorizationFlowRequest? authorizationFlow = null, Dictionary? metadata = null, - RiskAssessment? riskAssessment = null) + RiskAssessment? riskAssessment = null, + SubMerchants? subMerchants = null) { AmountInMinor = amountInMinor.GreaterThan(0, nameof(amountInMinor)); Currency = currency.NotNullOrWhiteSpace(nameof(currency)); @@ -42,6 +44,7 @@ public CreatePaymentRequest( AuthorizationFlow = authorizationFlow; Metadata = metadata; RiskAssessment = riskAssessment; + SubMerchants = subMerchants; } /// @@ -84,5 +87,10 @@ public CreatePaymentRequest( /// Gets the risk assessment configuration /// public RiskAssessment? RiskAssessment { get; } + + /// + /// Gets the sub-merchants information for the payment + /// + public SubMerchants? SubMerchants { get; } } } diff --git a/src/TrueLayer/Payments/Model/SubMerchants.cs b/src/TrueLayer/Payments/Model/SubMerchants.cs new file mode 100644 index 00000000..b5f85f4d --- /dev/null +++ b/src/TrueLayer/Payments/Model/SubMerchants.cs @@ -0,0 +1,130 @@ +using OneOf; +using TrueLayer.Common; +using TrueLayer.Serialization; + +namespace TrueLayer.Payments.Model +{ + using UltimateCounterpartyUnion = OneOf; + + /// + /// Represents sub-merchants information for payment requests + /// + public class SubMerchants + { + /// + /// Creates a new instance + /// + /// The ultimate counterparty information + public SubMerchants(UltimateCounterpartyUnion ultimateCounterparty) + { + UltimateCounterparty = ultimateCounterparty; + } + + /// + /// Gets the ultimate counterparty information + /// + public UltimateCounterpartyUnion UltimateCounterparty { get; } + + /// + /// Represents a business division counterparty + /// + [JsonDiscriminator("business_division")] + public class BusinessDivision + { + /// + /// Creates a new instance + /// + /// UUID generated by you + /// Name of the division + public BusinessDivision(string id, string name) + { + Type = "business_division"; + Id = id.NotNullOrWhiteSpace(nameof(id)); + Name = name.NotNullOrWhiteSpace(nameof(name)); + } + + /// + /// Gets the type of the counterparty + /// + public string Type { get; } + + /// + /// Gets the UUID generated by you + /// + public string Id { get; } + + /// + /// Gets the name of the division + /// + public string Name { get; } + } + + /// + /// Represents a business client counterparty + /// + [JsonDiscriminator("business_client")] + public class BusinessClient + { + /// + /// Creates a new instance + /// + /// Trading name of the merchant + /// Commercial name different from trading name (optional) + /// Business website URL (optional) + /// Merchant category code (optional) + /// Business registration number (optional if address provided) + /// Business address (optional) + public BusinessClient( + string tradingName, + string? commercialName = null, + string? url = null, + string? mcc = null, + string? registrationNumber = null, + Address? address = null) + { + Type = "business_client"; + TradingName = tradingName.NotNullOrWhiteSpace(nameof(tradingName)); + CommercialName = commercialName.NotEmptyOrWhiteSpace(nameof(commercialName)); + Url = url.NotEmptyOrWhiteSpace(nameof(url)); + Mcc = mcc.NotEmptyOrWhiteSpace(nameof(mcc)); + RegistrationNumber = registrationNumber.NotEmptyOrWhiteSpace(nameof(registrationNumber)); + Address = address; + } + + /// + /// Gets the type of the counterparty + /// + public string Type { get; } + + /// + /// Gets the trading name of the merchant + /// + public string TradingName { get; } + + /// + /// Gets the commercial name different from trading name + /// + public string? CommercialName { get; } + + /// + /// Gets the business website URL + /// + public string? Url { get; } + + /// + /// Gets the merchant category code + /// + public string? Mcc { get; } + + /// + /// Gets the business registration number + /// + public string? RegistrationNumber { get; } + + /// + /// Gets the business address + /// + public Address? Address { get; } + } + } +} \ No newline at end of file diff --git a/src/TrueLayer/Payouts/Model/AccountIdentifier.cs b/src/TrueLayer/Payouts/Model/AccountIdentifier.cs index dba4b159..65b4a0ae 100644 --- a/src/TrueLayer/Payouts/Model/AccountIdentifier.cs +++ b/src/TrueLayer/Payouts/Model/AccountIdentifier.cs @@ -73,5 +73,39 @@ public SortCodeAccountNumber(string sortCode, string accountNumber) public string AccountNumber { get; } } + + /// + /// Defines a bank account identified by a Polish NRB + /// + /// + [JsonDiscriminator(Discriminator)] + public record Nrb : IDiscriminated + { + public const string Discriminator = "nrb"; + + /// + /// Creates a new instance + /// + /// + /// Valid Polish NRB (no spaces). + /// Consists of 2 check digits, followed by an 8 digit bank branch number, and then by a 16 digit bank account number. + /// Equivalent to a Polish IBAN with the country code removed. + /// + public Nrb(string value) + { + Value = value.NotNullOrWhiteSpace(nameof(value)); + } + + /// + /// Gets the scheme identifier type + /// + public string Type => Discriminator; + + /// + /// Gets the NRB value + /// + [JsonPropertyName(Discriminator)] + public string Value { get; } + } } } diff --git a/src/TrueLayer/Payouts/Model/Beneficiary.cs b/src/TrueLayer/Payouts/Model/Beneficiary.cs index 06528f0f..d900ae1e 100644 --- a/src/TrueLayer/Payouts/Model/Beneficiary.cs +++ b/src/TrueLayer/Payouts/Model/Beneficiary.cs @@ -7,7 +7,7 @@ namespace TrueLayer.Payouts.Model { - using AccountIdentifierUnion = OneOf; + using AccountIdentifierUnion = OneOf; public static class Beneficiary { diff --git a/test/TrueLayer.AcceptanceTests/PaymentTests.cs b/test/TrueLayer.AcceptanceTests/PaymentTests.cs index 23af6e2e..216107bc 100644 --- a/test/TrueLayer.AcceptanceTests/PaymentTests.cs +++ b/test/TrueLayer.AcceptanceTests/PaymentTests.cs @@ -164,6 +164,67 @@ public async Task Can_Create_Merchant_Account_Eur_Payment() hppUri.Should().NotBeNullOrWhiteSpace(); } + [Fact] + public async Task Can_Create_Payment_With_SubMerchants_BusinessDivision() + { + var subMerchants = new SubMerchants(new SubMerchants.BusinessDivision( + id: Guid.NewGuid().ToString(), + name: "Test Division")); + + var paymentRequest = CreateTestPaymentRequest( + new Provider.UserSelected + { + Filter = new ProviderFilter { ProviderIds = ["mock-payments-gb-redirect"] }, + SchemeSelection = new SchemeSelection.InstantOnly { AllowRemitterFee = true }, + }, + subMerchants: subMerchants); + + var response = await _fixture.TlClients[0].Payments.CreatePayment( + paymentRequest, idempotencyKey: Guid.NewGuid().ToString()); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var authorizationRequired = response.Data.AsT0; + + authorizationRequired.Id.Should().NotBeNullOrWhiteSpace(); + authorizationRequired.ResourceToken.Should().NotBeNullOrWhiteSpace(); + authorizationRequired.User.Should().NotBeNull(); + authorizationRequired.User.Id.Should().NotBeNullOrWhiteSpace(); + authorizationRequired.Status.Should().Be("authorization_required"); + } + + [Fact] + public async Task Can_Create_Payment_With_SubMerchants_BusinessClient() + { + var address = new Address("London", "England", "EC1R 4RB", "GB", "1 Hardwick St"); + var subMerchants = new SubMerchants(new SubMerchants.BusinessClient( + tradingName: "Test Trading Company", + commercialName: "Test Commercial Name", + url: "https://example.com", + mcc: "1234", + registrationNumber: "REG123456", + address: address)); + + var paymentRequest = CreateTestPaymentRequest( + new Provider.UserSelected + { + Filter = new ProviderFilter { ProviderIds = ["mock-payments-gb-redirect"] }, + SchemeSelection = new SchemeSelection.InstantOnly { AllowRemitterFee = true }, + }, + subMerchants: subMerchants); + + var response = await _fixture.TlClients[0].Payments.CreatePayment( + paymentRequest, idempotencyKey: Guid.NewGuid().ToString()); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var authorizationRequired = response.Data.AsT0; + + authorizationRequired.Id.Should().NotBeNullOrWhiteSpace(); + authorizationRequired.ResourceToken.Should().NotBeNullOrWhiteSpace(); + authorizationRequired.User.Should().NotBeNull(); + authorizationRequired.User.Id.Should().NotBeNullOrWhiteSpace(); + authorizationRequired.Status.Should().Be("authorization_required"); + } + [Fact] public async Task Can_Create_Payment_With_Auth_Flow() { @@ -530,7 +591,8 @@ private static CreatePaymentRequest CreateTestPaymentRequest( RelatedProducts? relatedProducts = null, BeneficiaryUnion? beneficiary = null, Retry.BaseRetry? retry = null, - bool initAuthorizationFlow = false) + bool initAuthorizationFlow = false, + SubMerchants? subMerchants = null) { accountIdentifier ??= new AccountIdentifier.SortCodeAccountNumber("567890", "12345678"); providerSelection ??= new Provider.Preselected("mock-payments-gb-redirect", @@ -568,7 +630,8 @@ private static CreatePaymentRequest CreateTestPaymentRequest( ["test-key-1"] = "test-value-1", ["test-key-2"] = "test-value-2", }, - riskAssessment: new RiskAssessment("test") + riskAssessment: new RiskAssessment("test"), + subMerchants: subMerchants ); } diff --git a/test/TrueLayer.AcceptanceTests/PayoutTests.cs b/test/TrueLayer.AcceptanceTests/PayoutTests.cs index cd13727c..8e5fce3b 100644 --- a/test/TrueLayer.AcceptanceTests/PayoutTests.cs +++ b/test/TrueLayer.AcceptanceTests/PayoutTests.cs @@ -30,6 +30,18 @@ public async Task Can_create_payout() response.Data!.Id.Should().NotBeNullOrWhiteSpace(); } + [Fact] + public async Task Can_create_pln_payout() + { + CreatePayoutRequest payoutRequest = CreatePlnPayoutRequest(); + + var response = await _fixture.TlClients[0].Payouts.CreatePayout(payoutRequest); + + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + response.Data.Should().NotBeNull(); + response.Data!.Id.Should().NotBeNullOrWhiteSpace(); + } + [Fact] public async Task Can_get_payout() { @@ -83,5 +95,20 @@ private CreatePayoutRequest CreatePayoutRequest() metadata: new() { { "a", "b" } }, schemeSelection: new SchemeSelection.InstantOnly() ); + + private static CreatePayoutRequest CreatePlnPayoutRequest() + => new( + "fdb6007b-78c0-dbc0-60dd-d4c6f6908e3b", //pln merchant account + 100, + Currencies.PLN, + new Beneficiary.ExternalAccount( + "Ms. Lucky", + "truelayer-dotnet", + new AccountIdentifier.Iban("GB25CLRB04066800046876"), + dateOfBirth: new DateTime(1970, 12, 31), + address: new Address("London", "England", "EC1R 4RB", "GB", "1 Hardwick St")), + metadata: new() { { "a", "b" } }, + schemeSelection: new SchemeSelection.InstantOnly() + ); } } diff --git a/test/TrueLayer.Tests/Payments/SubMerchantsTests.cs b/test/TrueLayer.Tests/Payments/SubMerchantsTests.cs new file mode 100644 index 00000000..009afa51 --- /dev/null +++ b/test/TrueLayer.Tests/Payments/SubMerchantsTests.cs @@ -0,0 +1,153 @@ +using System; +using FluentAssertions; +using TrueLayer.Common; +using TrueLayer.Payments.Model; +using Xunit; + +namespace TrueLayer.Tests.Payments +{ + public class SubMerchantsTests + { + [Fact] + public void BusinessDivision_Constructor_Should_Set_Properties_Correctly() + { + // Arrange + var id = Guid.NewGuid().ToString(); + var name = "Test Division"; + + // Act + var businessDivision = new SubMerchants.BusinessDivision(id, name); + + // Assert + businessDivision.Type.Should().Be("business_division"); + businessDivision.Id.Should().Be(id); + businessDivision.Name.Should().Be(name); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void BusinessDivision_Constructor_Should_Throw_When_Id_Is_Invalid(string? id) + { + // Act & Assert + var exception = Assert.Throws(() => new SubMerchants.BusinessDivision(id!, "Test Division")); + exception.ParamName.Should().Be("id"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void BusinessDivision_Constructor_Should_Throw_When_Name_Is_Invalid(string? name) + { + // Act & Assert + var exception = Assert.Throws(() => new SubMerchants.BusinessDivision("test-id", name!)); + exception.ParamName.Should().Be("name"); + } + + [Fact] + public void BusinessClient_Constructor_Should_Set_Required_Properties_Correctly() + { + // Arrange + var tradingName = "Test Trading Name"; + + // Act + var businessClient = new SubMerchants.BusinessClient(tradingName); + + // Assert + businessClient.Type.Should().Be("business_client"); + businessClient.TradingName.Should().Be(tradingName); + businessClient.CommercialName.Should().BeNull(); + businessClient.Url.Should().BeNull(); + businessClient.Mcc.Should().BeNull(); + businessClient.RegistrationNumber.Should().BeNull(); + businessClient.Address.Should().BeNull(); + } + + [Fact] + public void BusinessClient_Constructor_Should_Set_All_Properties_Correctly() + { + // Arrange + var tradingName = "Test Trading Name"; + var commercialName = "Test Commercial Name"; + var url = "https://example.com"; + var mcc = "1234"; + var registrationNumber = "REG123456"; + var address = new Address("London", "England", "EC1R 4RB", "GB", "1 Hardwick St"); + + // Act + var businessClient = new SubMerchants.BusinessClient( + tradingName, commercialName, url, mcc, registrationNumber, address); + + // Assert + businessClient.Type.Should().Be("business_client"); + businessClient.TradingName.Should().Be(tradingName); + businessClient.CommercialName.Should().Be(commercialName); + businessClient.Url.Should().Be(url); + businessClient.Mcc.Should().Be(mcc); + businessClient.RegistrationNumber.Should().Be(registrationNumber); + businessClient.Address.Should().Be(address); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void BusinessClient_Constructor_Should_Throw_When_TradingName_Is_Invalid(string? tradingName) + { + // Act & Assert + var exception = Assert.Throws(() => new SubMerchants.BusinessClient(tradingName!)); + exception.ParamName.Should().Be("tradingName"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void BusinessClient_Constructor_Should_Throw_When_Optional_Strings_Are_Empty_Or_Whitespace(string emptyValue) + { + // Act & Assert + var exception = Assert.Throws(() => new SubMerchants.BusinessClient( + "Trading Name", emptyValue, emptyValue, emptyValue, emptyValue)); + exception.ParamName.Should().Be("commercialName"); + } + + [Fact] + public void SubMerchants_Constructor_Should_Set_UltimateCounterparty_With_BusinessDivision() + { + // Arrange + var businessDivision = new SubMerchants.BusinessDivision("test-id", "Test Division"); + + // Act + var subMerchants = new SubMerchants(businessDivision); + + // Assert + subMerchants.UltimateCounterparty.IsT0.Should().BeTrue(); // BusinessDivision + var result = subMerchants.UltimateCounterparty.AsT0; + result.Type.Should().Be("business_division"); + result.Id.Should().Be("test-id"); + result.Name.Should().Be("Test Division"); + } + + [Fact] + public void SubMerchants_Constructor_Should_Set_UltimateCounterparty_With_BusinessClient() + { + // Arrange + var businessClient = new SubMerchants.BusinessClient("Test Trading Name"); + + // Act + var subMerchants = new SubMerchants(businessClient); + + // Assert + subMerchants.UltimateCounterparty.IsT1.Should().BeTrue(); // BusinessClient + var result = subMerchants.UltimateCounterparty.AsT1; + result.Type.Should().Be("business_client"); + result.TradingName.Should().Be("Test Trading Name"); + result.CommercialName.Should().BeNull(); + result.Url.Should().BeNull(); + result.Mcc.Should().BeNull(); + result.RegistrationNumber.Should().BeNull(); + result.Address.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/TrueLayer.Tests/Serialization/SubMerchantsSerializationTests.cs b/test/TrueLayer.Tests/Serialization/SubMerchantsSerializationTests.cs new file mode 100644 index 00000000..0d0c8ec2 --- /dev/null +++ b/test/TrueLayer.Tests/Serialization/SubMerchantsSerializationTests.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using FluentAssertions; +using TrueLayer.Common; +using TrueLayer.Payments.Model; +using TrueLayer.Serialization; +using Xunit; + +namespace TrueLayer.Tests.Serialization +{ + public class SubMerchantsSerializationTests + { + [Fact] + public void BusinessDivision_Should_Serialize_And_Deserialize_Correctly() + { + // Arrange + var businessDivision = new SubMerchants.BusinessDivision("test-id-123", "Test Division Name"); + var subMerchants = new SubMerchants(businessDivision); + + // Act + string json = JsonSerializer.Serialize(subMerchants, SerializerOptions.Default); + var deserialized = JsonSerializer.Deserialize(json, SerializerOptions.Default); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.UltimateCounterparty.IsT0.Should().BeTrue(); + var deserializedBusinessDivision = deserialized.UltimateCounterparty.AsT0; + deserializedBusinessDivision.Type.Should().Be("business_division"); + deserializedBusinessDivision.Id.Should().Be("test-id-123"); + deserializedBusinessDivision.Name.Should().Be("Test Division Name"); + } + + [Fact] + public void BusinessClient_With_All_Properties_Should_Serialize_And_Deserialize_Correctly() + { + // Arrange + var address = new Address("London", "England", "EC1R 4RB", "GB", "1 Hardwick St"); + var businessClient = new SubMerchants.BusinessClient( + tradingName: "Test Trading Company", + commercialName: "Test Commercial Name", + url: "https://example.com", + mcc: "1234", + registrationNumber: "REG123456", + address: address); + var subMerchants = new SubMerchants(businessClient); + + // Act + string json = JsonSerializer.Serialize(subMerchants, SerializerOptions.Default); + var deserialized = JsonSerializer.Deserialize(json, SerializerOptions.Default); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.UltimateCounterparty.IsT1.Should().BeTrue(); + var deserializedBusinessClient = deserialized.UltimateCounterparty.AsT1; + deserializedBusinessClient.Type.Should().Be("business_client"); + deserializedBusinessClient.TradingName.Should().Be("Test Trading Company"); + deserializedBusinessClient.CommercialName.Should().Be("Test Commercial Name"); + deserializedBusinessClient.Url.Should().Be("https://example.com"); + deserializedBusinessClient.Mcc.Should().Be("1234"); + deserializedBusinessClient.RegistrationNumber.Should().Be("REG123456"); + deserializedBusinessClient.Address.Should().NotBeNull(); + deserializedBusinessClient.Address!.AddressLine1.Should().Be("1 Hardwick St"); + deserializedBusinessClient.Address.City.Should().Be("London"); + deserializedBusinessClient.Address.State.Should().Be("England"); + deserializedBusinessClient.Address.Zip.Should().Be("EC1R 4RB"); + deserializedBusinessClient.Address.CountryCode.Should().Be("GB"); + } + + [Fact] + public void BusinessClient_With_Required_Properties_Only_Should_Serialize_And_Deserialize_Correctly() + { + // Arrange + var businessClient = new SubMerchants.BusinessClient("Test Trading Company"); + var subMerchants = new SubMerchants(businessClient); + + // Act + string json = JsonSerializer.Serialize(subMerchants, SerializerOptions.Default); + var deserialized = JsonSerializer.Deserialize(json, SerializerOptions.Default); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.UltimateCounterparty.IsT1.Should().BeTrue(); + var deserializedBusinessClient = deserialized.UltimateCounterparty.AsT1; + deserializedBusinessClient.Type.Should().Be("business_client"); + deserializedBusinessClient.TradingName.Should().Be("Test Trading Company"); + deserializedBusinessClient.CommercialName.Should().BeNull(); + deserializedBusinessClient.Url.Should().BeNull(); + deserializedBusinessClient.Mcc.Should().BeNull(); + deserializedBusinessClient.RegistrationNumber.Should().BeNull(); + deserializedBusinessClient.Address.Should().BeNull(); + } + + [Fact] + public void BusinessDivision_Should_Serialize_With_Snake_Case_Property_Names() + { + // Arrange + var businessDivision = new SubMerchants.BusinessDivision("test-id-123", "Test Division Name"); + var subMerchants = new SubMerchants(businessDivision); + + // Act + string json = JsonSerializer.Serialize(subMerchants, SerializerOptions.Default); + + // Assert + json.Should().Contain("\"ultimate_counterparty\""); + json.Should().Contain("\"type\":\"business_division\""); + json.Should().Contain("\"id\":\"test-id-123\""); + json.Should().Contain("\"name\":\"Test Division Name\""); + } + + [Fact] + public void BusinessClient_Should_Serialize_With_Snake_Case_Property_Names() + { + // Arrange + var businessClient = new SubMerchants.BusinessClient( + tradingName: "Test Trading Company", + commercialName: "Test Commercial Name", + url: "https://example.com", + mcc: "1234", + registrationNumber: "REG123456"); + var subMerchants = new SubMerchants(businessClient); + + // Act + string json = JsonSerializer.Serialize(subMerchants, SerializerOptions.Default); + + // Assert + json.Should().Contain("\"ultimate_counterparty\""); + json.Should().Contain("\"type\":\"business_client\""); + json.Should().Contain("\"trading_name\":\"Test Trading Company\""); + json.Should().Contain("\"commercial_name\":\"Test Commercial Name\""); + json.Should().Contain("\"registration_number\":\"REG123456\""); + } + } +} \ No newline at end of file