diff --git a/CHANGELOG.md b/CHANGELOG.md index d3792b31..eacddce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- Added support for **Verified Payouts** (UK-only feature) + - New `Verification` model with `VerifyName` and optional `TransactionSearchCriteria` for name and transaction verification + - New `PayoutUserRequest` and `PayoutUserResponse` models for user details in verified payouts + - Updated `CreatePayoutBeneficiary.UserDetermined` beneficiary type with verification support + - New `PayoutHppLinkBuilder` helper for generating Hosted Payment Page verification links + - New `CreatePayoutResponse.AuthorizationRequired` response type for verified payouts requiring authorization + - Separate `GetPayoutBeneficiary` namespace for GET response types (distinct from CREATE request types) + - Enhanced `OneOfJsonConverter` with `[DefaultJsonDiscriminator]` support for fallback deserialization + +### Changed +- **BREAKING**: `CreatePayout` now returns `OneOf` instead of `CreatePayoutResponse` + - Consumers must use `.Match()` to handle both response types + - Standard payouts return `Created` with just the payout ID + - Verified payouts return `AuthorizationRequired` with ID, status, resource token, and user details +- **BREAKING**: `GetPayout` now returns `OneOf` instead of `OneOf` + - Added `AuthorizationRequired` status for verified payouts awaiting user authorization + - Consumers must update `.Match()` calls to handle the new `AuthorizationRequired` type +- **BREAKING**: Renamed `Beneficiary` to `CreatePayoutBeneficiary` for clarity +- **BREAKING**: Simplified `CreatePayoutBeneficiary.BusinessAccount` to only require `Reference` (removed account holder name and identifier) +- Updated `CreatePayoutBeneficiary.PaymentSource` GET response to include `AccountHolderName` and `AccountIdentifiers` +- Updated `GetPayout` to return `GetPayoutBeneficiary` types with populated account details + ### Removed - Removed support for .NET 6.0 diff --git a/examples/MvcExample/Controllers/PayoutController.cs b/examples/MvcExample/Controllers/PayoutController.cs index 9b1b9738..445593f1 100644 --- a/examples/MvcExample/Controllers/PayoutController.cs +++ b/examples/MvcExample/Controllers/PayoutController.cs @@ -9,6 +9,8 @@ using TrueLayer.Common; using TrueLayer.Payouts.Model; using static TrueLayer.Payouts.Model.GetPayoutsResponse; +using static TrueLayer.Payouts.Model.CreatePayoutResponse; +using Beneficiary = TrueLayer.Payouts.Model.CreatePayoutBeneficiary; namespace MvcExample.Controllers { @@ -72,10 +74,15 @@ public async Task CreatePayout(PayoutModel payoutModel) return View("Index"); } + // Extract the payout ID from the response (works for both Created and AuthorizationRequired) + var payoutId = apiResponse.Data!.Match( + authRequired => authRequired.Id, + created => created.Id); + var redirectLink = new Uri(string.Join( "/", Url.ActionLink("Complete").TrimEnd('/'), - $"?payoutId={apiResponse.Data?.Id}")); + $"?payoutId={payoutId}")); return Redirect(redirectLink.AbsoluteUri); } @@ -112,7 +119,8 @@ IActionResult Pending(PayoutDetails payout) return Failed(apiResponse.StatusCode.ToString()); return apiResponse.Data.Match( - authorizing => Pending(authorizing), + authorizationRequired => Pending(authorizationRequired), + pending => Pending(pending), authorized => Success(authorized), executed => Success(executed), failed => Failed(failed.Status) diff --git a/src/TrueLayer/Guard.cs b/src/TrueLayer/Guard.cs index 3476cc09..a99a7cdf 100644 --- a/src/TrueLayer/Guard.cs +++ b/src/TrueLayer/Guard.cs @@ -1,7 +1,6 @@ using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using TrueLayer.Common; namespace TrueLayer { diff --git a/src/TrueLayer/MerchantAccounts/Model/GetTransactions.cs b/src/TrueLayer/MerchantAccounts/Model/GetTransactions.cs index 94d529bd..b2118d18 100644 --- a/src/TrueLayer/MerchantAccounts/Model/GetTransactions.cs +++ b/src/TrueLayer/MerchantAccounts/Model/GetTransactions.cs @@ -4,7 +4,7 @@ using TrueLayer.Models; using TrueLayer.Payments.Model; using TrueLayer.Serialization; -using PayoutBeneficiary = TrueLayer.Payouts.Model.Beneficiary; +using PayoutBeneficiary = TrueLayer.Payouts.Model.GetPayoutBeneficiary; using static TrueLayer.MerchantAccounts.Model.MerchantAccountTransactions; namespace TrueLayer.MerchantAccounts.Model; diff --git a/src/TrueLayer/Payouts/IPayoutsApi.cs b/src/TrueLayer/Payouts/IPayoutsApi.cs index 6ecb7021..5af6cb2a 100644 --- a/src/TrueLayer/Payouts/IPayoutsApi.cs +++ b/src/TrueLayer/Payouts/IPayoutsApi.cs @@ -2,15 +2,21 @@ using System.Threading.Tasks; using OneOf; using TrueLayer.Payouts.Model; -using static TrueLayer.Payouts.Model.GetPayoutsResponse; +using static TrueLayer.Payouts.Model.CreatePayoutResponse; namespace TrueLayer.Payouts { using GetPayoutUnion = OneOf< - Pending, - Authorized, - Executed, - Failed + GetPayoutsResponse.AuthorizationRequired, + GetPayoutsResponse.Pending, + GetPayoutsResponse.Authorized, + GetPayoutsResponse.Executed, + GetPayoutsResponse.Failed + >; + + using CreatePayoutUnion = OneOf< + AuthorizationRequired, + Created >; /// @@ -29,7 +35,7 @@ public interface IPayoutsApi /// /// The cancellation token to cancel the operation /// An API response that includes details of the created payout if successful, otherwise problem details - Task> CreatePayout( + Task> CreatePayout( CreatePayoutRequest payoutRequest, string? idempotencyKey = null, CancellationToken cancellationToken = default); diff --git a/src/TrueLayer/Payouts/Model/Beneficiary.cs b/src/TrueLayer/Payouts/Model/CreatePayoutBeneficiary.cs similarity index 73% rename from src/TrueLayer/Payouts/Model/Beneficiary.cs rename to src/TrueLayer/Payouts/Model/CreatePayoutBeneficiary.cs index d900ae1e..14d037cf 100644 --- a/src/TrueLayer/Payouts/Model/Beneficiary.cs +++ b/src/TrueLayer/Payouts/Model/CreatePayoutBeneficiary.cs @@ -3,13 +3,17 @@ using OneOf; using TrueLayer.Common; using TrueLayer.Serialization; +using Provider = TrueLayer.Payments.Model.Provider; using static TrueLayer.Payouts.Model.AccountIdentifier; namespace TrueLayer.Payouts.Model { using AccountIdentifierUnion = OneOf; - public static class Beneficiary + /// + /// Beneficiary types for CREATE payout requests + /// + public static class CreatePayoutBeneficiary { /// /// Represents a TrueLayer beneficiary merchant account @@ -118,17 +122,10 @@ public sealed record BusinessAccount : IDiscriminated /// /// Creates a new . /// - /// A reference for the payout. - /// The business account holder name - /// The unique identifier for the business account - public BusinessAccount( - string reference, - string? accountHolderName = null, - AccountIdentifierUnion? accountIdentifier = null) + /// A reference for the payout + public BusinessAccount(string reference) { Reference = reference.NotNullOrWhiteSpace(nameof(reference)); - AccountHolderName = accountHolderName; - AccountIdentifier = accountIdentifier; } /// @@ -137,49 +134,62 @@ public BusinessAccount( public string Type => Discriminator; /// - /// Gets the name of the business account holder - /// - public string? AccountHolderName { get; } = ""; - - /// - /// Gets the reference for the business bank account holder + /// Gets the reference for the payout /// public string Reference { get; } - - /// - /// Gets the unique scheme identifier for the business account - /// - public AccountIdentifierUnion? AccountIdentifier { get; } } + /// + /// Represents a beneficiary that is specified by the end-user during the verification flow + /// [JsonDiscriminator(Discriminator)] public sealed record UserDetermined : IDiscriminated { const string Discriminator = "user_determined"; - public UserDetermined(string reference, string accountHolderName, AccountIdentifierUnion accountIdentifier) + /// + /// Creates a new beneficiary for verified payouts + /// + /// The reference for the payout, which displays in the beneficiary's bank statement + /// Details of the beneficiary of the payment + /// Object that represents the verification process associated to the payout + /// Provider Selection used for User Determined beneficiaries + public UserDetermined( + string reference, + PayoutUserRequest user, + Verification verification, + OneOf providerSelection) { - AccountHolderName = accountHolderName; - AccountIdentifier = accountIdentifier; Reference = reference.NotNullOrWhiteSpace(nameof(reference)); + User = user.NotNull(nameof(user)); + Verification = verification.NotNull(nameof(verification)); + ProviderSelection = providerSelection.NotNull(nameof(providerSelection)); } + /// + /// Gets the type of beneficiary + /// public string Type => Discriminator; /// - /// Gets the name of the external account holder + /// Gets the reference for the payout, which displays in the beneficiary's bank statement /// - public string AccountHolderName { get; } = ""; + public string Reference { get; } /// - /// Gets the reference for the external bank account holder + /// Gets the user details of the beneficiary /// - public string Reference { get; } + public PayoutUserRequest User { get; } /// - /// Gets the unique scheme identifier for the external account + /// Gets the verification configuration /// - public AccountIdentifierUnion AccountIdentifier { get; } + public Verification Verification { get; } + + /// + /// Gets the provider selection configuration + /// + public OneOf ProviderSelection { get; } } } } diff --git a/src/TrueLayer/Payouts/Model/CreatePayoutRequest.cs b/src/TrueLayer/Payouts/Model/CreatePayoutRequest.cs index 6cf455c5..5f5d3f4f 100644 --- a/src/TrueLayer/Payouts/Model/CreatePayoutRequest.cs +++ b/src/TrueLayer/Payouts/Model/CreatePayoutRequest.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using OneOf; -using static TrueLayer.Payouts.Model.Beneficiary; +using static TrueLayer.Payouts.Model.CreatePayoutBeneficiary; namespace TrueLayer.Payouts.Model { - using BeneficiaryUnion = OneOf; + using BeneficiaryUnion = OneOf; using SchemeSelectionUnion = OneOf; /// diff --git a/src/TrueLayer/Payouts/Model/CreatePayoutResponse.cs b/src/TrueLayer/Payouts/Model/CreatePayoutResponse.cs index 7277c501..d91f2491 100644 --- a/src/TrueLayer/Payouts/Model/CreatePayoutResponse.cs +++ b/src/TrueLayer/Payouts/Model/CreatePayoutResponse.cs @@ -1,8 +1,51 @@ +using TrueLayer.Serialization; + namespace TrueLayer.Payouts.Model { /// - /// Create Payout Response + /// Create Payout Response Types /// - /// The unique identifier of the payout - public record CreatePayoutResponse(string Id); + public static class CreatePayoutResponse + { + /// + /// Base class containing common properties for payout creation responses + /// + public record PayoutCreated + { + /// + /// Gets the unique identifier of the payout + /// + public string Id { get; init; } = null!; + } + + /// + /// Represents a verified payout that requires further authorization (user_determined beneficiary) + /// + [JsonDiscriminator("authorization_required")] + public record AuthorizationRequired : PayoutCreated + { + /// + /// Gets the status of the payout (always "authorization_required") + /// + public string Status { get; init; } = null!; + + /// + /// Gets the token used to complete the payout verification via a front-end channel + /// + public string ResourceToken { get; init; } = null!; + + /// + /// Gets the end user details + /// + public PayoutUserResponse User { get; init; } = null!; + } + + /// + /// Represents a standard payout (external_account, payment_source, or business_account beneficiary) + /// + [DefaultJsonDiscriminator] + public record Created : PayoutCreated + { + } + } } diff --git a/src/TrueLayer/Payouts/Model/GetPayoutBeneficiary.cs b/src/TrueLayer/Payouts/Model/GetPayoutBeneficiary.cs new file mode 100644 index 00000000..a563b677 --- /dev/null +++ b/src/TrueLayer/Payouts/Model/GetPayoutBeneficiary.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using OneOf; +using TrueLayer.Common; +using TrueLayer.Serialization; +using static TrueLayer.Payouts.Model.AccountIdentifier; + +namespace TrueLayer.Payouts.Model +{ + using AccountIdentifierUnion = OneOf; + + /// + /// Beneficiary types for GET payout responses + /// + public static class GetPayoutBeneficiary + { + /// + /// Represents a TrueLayer beneficiary merchant account (GET response) + /// + [JsonDiscriminator("payment_source")] + public sealed record PaymentSource : IDiscriminated + { + const string Discriminator = "payment_source"; + + /// + /// Gets the type of beneficiary + /// + public string Type => Discriminator; + + /// + /// Gets the ID of the external account which has become a payment source + /// + public string PaymentSourceId { get; init; } = null!; + + /// + /// Gets the ID of the owning user of the external account + /// + public string UserId { get; init; } = null!; + + /// + /// Gets the reference for the payout + /// + public string Reference { get; init; } = null!; + + /// + /// Gets the name of the account holder + /// + public string AccountHolderName { get; init; } = null!; + + /// + /// Gets the account identifiers + /// + public List AccountIdentifiers { get; init; } = null!; + } + + /// + /// Represents an external beneficiary account + /// + [JsonDiscriminator("external_account")] + public sealed record ExternalAccount : IDiscriminated + { + const string Discriminator = "external_account"; + + public string Type => Discriminator; + public string AccountHolderName { get; init; } = null!; + public string Reference { get; init; } = null!; + public AccountIdentifierUnion AccountIdentifier { get; init; } + } + + /// + /// Represents a client's preconfigured business account + /// + [JsonDiscriminator("business_account")] + public sealed record BusinessAccount : IDiscriminated + { + const string Discriminator = "business_account"; + + public string Type => Discriminator; + public string? AccountHolderName { get; init; } + public string Reference { get; init; } = null!; + public AccountIdentifierUnion? AccountIdentifier { get; init; } + } + + /// + /// Represents a user-determined beneficiary + /// + [JsonDiscriminator("user_determined")] + public sealed record UserDetermined : IDiscriminated + { + const string Discriminator = "user_determined"; + + public string Type => Discriminator; + + /// + /// Gets the reference for the payout, which displays in the beneficiary's bank statement + /// + public string Reference { get; init; } = null!; + + /// + /// Gets the user details of the beneficiary + /// + public PayoutUserResponse User { get; init; } = null!; + + /// + /// Gets the name of the account holder + /// + public string? AccountHolderName { get; init; } + + /// + /// Gets the account identifiers + /// + public List? AccountIdentifiers { get; init; } + + /// + /// Gets the verification details that were requested for the payout + /// + public Verification? Verification { get; init; } + } + } +} diff --git a/src/TrueLayer/Payouts/Model/GetPayoutsResponse.cs b/src/TrueLayer/Payouts/Model/GetPayoutsResponse.cs index 5db2b168..c03617d0 100644 --- a/src/TrueLayer/Payouts/Model/GetPayoutsResponse.cs +++ b/src/TrueLayer/Payouts/Model/GetPayoutsResponse.cs @@ -2,12 +2,12 @@ using System.Collections.Generic; using OneOf; using TrueLayer.Serialization; -using static TrueLayer.Payouts.Model.Beneficiary; +using static TrueLayer.Payouts.Model.GetPayoutBeneficiary; namespace TrueLayer.Payouts.Model { - using BeneficiaryUnion = OneOf; + using GetPayoutBeneficiaryUnion = OneOf; /// /// Get Payout Response Types @@ -44,7 +44,7 @@ public record PayoutDetails /// /// Gets the beneficiary details /// - public BeneficiaryUnion Beneficiary { get; init; } + public GetPayoutBeneficiaryUnion Beneficiary { get; init; } /// /// Gets the status of the payout @@ -68,6 +68,12 @@ public record PayoutDetails public Dictionary? Metadata { get; init; } } + /// + /// Represents a verified payout that requires authorization + /// + [JsonDiscriminator("authorization_required")] + public record AuthorizationRequired : PayoutDetails; + /// /// Represents a payout that is pending /// diff --git a/src/TrueLayer/Payouts/Model/PayoutHppLinkBuilder.cs b/src/TrueLayer/Payouts/Model/PayoutHppLinkBuilder.cs new file mode 100644 index 00000000..cce031f1 --- /dev/null +++ b/src/TrueLayer/Payouts/Model/PayoutHppLinkBuilder.cs @@ -0,0 +1,60 @@ +using System; +using System.Web; + +namespace TrueLayer.Payouts.Model +{ + /// + /// Helper class for building verification Hosted Payment Page (HPP) links for verified payouts + /// + public static class PayoutHppLinkBuilder + { + /// + /// Creates a verification HPP link for a verified payout + /// + /// The unique payout identifier + /// The resource token from the payout creation response + /// The URI to redirect to after verification + /// Whether to use sandbox environment (default: false) + /// The complete HPP link for the payout verification + /// If any of the required parameters are null or whitespace + public static string CreateVerificationLink( + string payoutId, + string resourceToken, + string returnUri, + bool useSandbox = false) + { + if (string.IsNullOrWhiteSpace(payoutId)) + throw new ArgumentException("Payout ID cannot be null or empty", nameof(payoutId)); + + if (string.IsNullOrWhiteSpace(resourceToken)) + throw new ArgumentException("Resource token cannot be null or empty", nameof(resourceToken)); + + if (string.IsNullOrWhiteSpace(returnUri)) + throw new ArgumentException("Return URI cannot be null or empty", nameof(returnUri)); + + var baseUrl = useSandbox + ? "https://app.truelayer-sandbox.com/payouts" + : "https://app.truelayer.com/payouts"; + + var encodedReturnUri = HttpUtility.UrlEncode(returnUri); + + return $"{baseUrl}#payout_id={payoutId}&resource_token={resourceToken}&return_uri={encodedReturnUri}"; + } + + /// + /// Creates a verification HPP link from a verified payout creation response + /// + /// The authorization required response from payout creation + /// The URI to redirect to after verification + /// Whether to use sandbox environment (default: false) + /// The complete HPP link for the payout verification + public static string CreateVerificationLink( + CreatePayoutResponse.AuthorizationRequired response, + string returnUri, + bool useSandbox = false) + { + response.NotNull(nameof(response)); + return CreateVerificationLink(response.Id, response.ResourceToken, returnUri, useSandbox); + } + } +} diff --git a/src/TrueLayer/Payouts/Model/PayoutUser.cs b/src/TrueLayer/Payouts/Model/PayoutUser.cs new file mode 100644 index 00000000..dac8a20f --- /dev/null +++ b/src/TrueLayer/Payouts/Model/PayoutUser.cs @@ -0,0 +1,78 @@ +using System; +using System.Text.Json.Serialization; +using TrueLayer.Common; +using TrueLayer.Serialization; + +namespace TrueLayer.Payouts.Model +{ + /// + /// Represents a payout user for user-determined beneficiaries + /// + public record PayoutUserRequest + { + /// + /// Creates a payout user + /// + /// A unique identifier for the user (optional, generated by TrueLayer if not provided) + /// The full first and last name of the end user (required if client is not regulated or verify_name is true) + /// The email address of the end user (required if not using own PISP licence, unless phone is provided) + /// The phone number of the end user with country code (required if not using own PISP licence, unless email is provided) + /// The date of birth of the end user in YYYY-MM-DD format + /// The physical address of the end user + public PayoutUserRequest( + string? id = null, + string? name = null, + string? email = null, + string? phone = null, + DateTime? dateOfBirth = null, + Address? address = null) + { + Id = id.NotEmptyOrWhiteSpace(nameof(id)); + Name = name.NotEmptyOrWhiteSpace(nameof(name)); + Email = email.NotEmptyOrWhiteSpace(nameof(email)); + Phone = phone.NotEmptyOrWhiteSpace(nameof(phone)); + DateOfBirth = dateOfBirth; + Address = address; + } + + /// + /// Gets a unique identifier for the user. If not provided, TrueLayer generates a value in the response. + /// + public string? Id { get; } + + /// + /// Gets the full first and last name of the end user (not initials). + /// Required if client is not regulated or verification.verify_name is set to true. + /// + public string? Name { get; } + + /// + /// Gets the email address of the end user according to RFC 2822. + /// If using own PISP licence this is optional, otherwise one of email/phone is required. + /// + public string? Email { get; } + + /// + /// Gets the phone number of the end user in formats recommended by ITU. + /// The country calling code must be included and prefixed with a +. + /// If using own PISP licence this is optional, otherwise one of email/phone is required. + /// + public string? Phone { get; } + + /// + /// Gets the date of birth of the end user, in YYYY-MM-DD format + /// + [JsonConverter(typeof(DateTimeDateOnlyJsonConverter))] + public DateTime? DateOfBirth { get; } + + /// + /// Gets the physical address of the end user + /// + public Address? Address { get; } + } + + /// + /// Represents a payout user response + /// + public record PayoutUserResponse(string Id); +} diff --git a/src/TrueLayer/Payouts/Model/Verification.cs b/src/TrueLayer/Payouts/Model/Verification.cs new file mode 100644 index 00000000..be80b3d0 --- /dev/null +++ b/src/TrueLayer/Payouts/Model/Verification.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using TrueLayer.Serialization; + +namespace TrueLayer.Payouts.Model +{ + /// + /// Represents transaction search criteria for payout verification + /// + public record TransactionSearchCriteria + { + /// + /// Creates a new + /// + /// List of search tokens to match against transaction descriptions + /// Transaction amount in minor currency unit + /// Three-letter ISO currency code + /// Transaction creation date + public TransactionSearchCriteria( + IEnumerable tokens, + long amountInMinor, + string currency, + DateTime createdAt) + { + Tokens = tokens.NotNull(nameof(tokens)); + AmountInMinor = amountInMinor.GreaterThan(0, nameof(amountInMinor)); + Currency = currency.NotNullOrWhiteSpace(nameof(currency)); + CreatedAt = createdAt; + } + + /// + /// Gets the list of search tokens to match against transaction descriptions + /// + public IEnumerable Tokens { get; } + + /// + /// Gets the transaction amount in minor currency unit + /// + public long AmountInMinor { get; } + + /// + /// Gets the three-letter ISO currency code + /// + public string Currency { get; } + + /// + /// Gets the transaction creation date + /// + [JsonConverter(typeof(DateTimeDateOnlyJsonConverter))] + public DateTime CreatedAt { get; } + } + + /// + /// Represents verification configuration for user-determined payouts + /// + public record Verification + { + /// + /// Creates a new with name verification only + /// + /// Whether to verify the account holder name + public Verification(bool verifyName) + { + VerifyName = verifyName; + } + + /// + /// Creates a new with name and transaction verification + /// + /// Whether to verify the account holder name + /// Transaction search criteria for verification + public Verification(bool verifyName, TransactionSearchCriteria transactionSearchCriteria) + { + VerifyName = verifyName; + TransactionSearchCriteria = transactionSearchCriteria.NotNull(nameof(transactionSearchCriteria)); + } + + /// + /// Gets whether to verify the account holder name + /// + public bool VerifyName { get; } + + /// + /// Gets the transaction search criteria for verification + /// + public TransactionSearchCriteria? TransactionSearchCriteria { get; } + } +} diff --git a/src/TrueLayer/Payouts/PayoutsApi.cs b/src/TrueLayer/Payouts/PayoutsApi.cs index a39232a4..d24822c7 100644 --- a/src/TrueLayer/Payouts/PayoutsApi.cs +++ b/src/TrueLayer/Payouts/PayoutsApi.cs @@ -6,15 +6,21 @@ using TrueLayer.Extensions; using TrueLayer.Models; using TrueLayer.Payouts.Model; -using static TrueLayer.Payouts.Model.GetPayoutsResponse; +using static TrueLayer.Payouts.Model.CreatePayoutResponse; namespace TrueLayer.Payouts { using GetPayoutUnion = OneOf< - Pending, - Authorized, - Executed, - Failed + GetPayoutsResponse.AuthorizationRequired, + GetPayoutsResponse.Pending, + GetPayoutsResponse.Authorized, + GetPayoutsResponse.Executed, + GetPayoutsResponse.Failed + >; + + using CreatePayoutUnion = OneOf< + AuthorizationRequired, + Created >; internal class PayoutsApi : IPayoutsApi @@ -37,7 +43,7 @@ public PayoutsApi(IApiClient apiClient, IAuthApi auth, TrueLayerOptions options) } /// - public async Task> CreatePayout( + public async Task> CreatePayout( CreatePayoutRequest payoutRequest, string? idempotencyKey = null, CancellationToken cancellationToken = default) @@ -51,7 +57,7 @@ public async Task> CreatePayout( return new(authResponse.StatusCode, authResponse.TraceId); } - return await _apiClient.PostAsync( + return await _apiClient.PostAsync( _baseUri, payoutRequest, idempotencyKey ?? Guid.NewGuid().ToString(), diff --git a/src/TrueLayer/Serialization/DefaultJsonDiscriminatorAttribute.cs b/src/TrueLayer/Serialization/DefaultJsonDiscriminatorAttribute.cs new file mode 100644 index 00000000..50fd5cc9 --- /dev/null +++ b/src/TrueLayer/Serialization/DefaultJsonDiscriminatorAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace TrueLayer.Serialization +{ + /// + /// Marks a type as the default/fallback discriminator type when no discriminator is found in the JSON + /// + [AttributeUsageAttribute(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + internal sealed class DefaultJsonDiscriminatorAttribute : Attribute + { + } +} diff --git a/src/TrueLayer/Serialization/OneOfJsonConverter.cs b/src/TrueLayer/Serialization/OneOfJsonConverter.cs index f26eeb0d..82822e53 100644 --- a/src/TrueLayer/Serialization/OneOfJsonConverter.cs +++ b/src/TrueLayer/Serialization/OneOfJsonConverter.cs @@ -57,6 +57,16 @@ public OneOfJsonConverter(OneOfTypeDescriptor descriptor, string discriminatorFi return InvokeDiscriminatorFactory(options, readerClone, typeFactory); } + // Fallback to the type marked with DefaultJsonDiscriminator attribute + // This handles cases like CreatePayoutResponse.Created which has no status field + var defaultType = _descriptor.TypeFactories.Values + .FirstOrDefault(tf => tf.FieldType.GetCustomAttributes(typeof(DefaultJsonDiscriminatorAttribute), false).Any()); + + if (defaultType != default) + { + return InvokeDiscriminatorFactory(options, readerClone, defaultType); + } + throw new JsonException($"Unknown discriminator {discriminator}"); } diff --git a/src/TrueLayer/TrueLayerClientFactory.cs b/src/TrueLayer/TrueLayerClientFactory.cs index 731077af..d4972540 100644 --- a/src/TrueLayer/TrueLayerClientFactory.cs +++ b/src/TrueLayer/TrueLayerClientFactory.cs @@ -1,5 +1,4 @@ using System; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using TrueLayer.Auth; using TrueLayer.Caching; diff --git a/test/TrueLayer.AcceptanceTests/PayoutTests.cs b/test/TrueLayer.AcceptanceTests/PayoutTests.cs index 8e5fce3b..df0fc82a 100644 --- a/test/TrueLayer.AcceptanceTests/PayoutTests.cs +++ b/test/TrueLayer.AcceptanceTests/PayoutTests.cs @@ -1,11 +1,18 @@ using System; +using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; using TrueLayer.Common; using TrueLayer.Payouts.Model; using Xunit; +using Beneficiary = TrueLayer.Payouts.Model.CreatePayoutBeneficiary; using static TrueLayer.Payouts.Model.GetPayoutsResponse; +using Provider = TrueLayer.Payments.Model.Provider; +using ProviderFilter = TrueLayer.Payments.Model.ProviderFilter; +using PayoutVerification = TrueLayer.Payouts.Model.Verification; +using PayoutSchemeSelection = TrueLayer.Payouts.Model.SchemeSelection; +using PayoutAccountIdentifier = TrueLayer.Payouts.Model.AccountIdentifier; namespace TrueLayer.AcceptanceTests { @@ -27,7 +34,13 @@ public async Task Can_create_payout() response.StatusCode.Should().Be(HttpStatusCode.Accepted); response.Data.Should().NotBeNull(); - response.Data!.Id.Should().NotBeNullOrWhiteSpace(); + + // Extract payout ID - can be either Created or AuthorizationRequired depending on API behavior + var payoutId = response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id); + + payoutId.Should().NotBeNullOrWhiteSpace(); } [Fact] @@ -39,7 +52,13 @@ public async Task Can_create_pln_payout() response.StatusCode.Should().Be(HttpStatusCode.Accepted); response.Data.Should().NotBeNull(); - response.Data!.Id.Should().NotBeNullOrWhiteSpace(); + + // Extract payout ID - can be either Created or AuthorizationRequired depending on API behavior + var payoutId = response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id); + + payoutId.Should().NotBeNullOrWhiteSpace(); } [Fact] @@ -52,16 +71,22 @@ public async Task Can_get_payout() response.StatusCode.Should().Be(HttpStatusCode.Accepted); response.Data.Should().NotBeNull(); - response.Data!.Id.Should().NotBeNullOrWhiteSpace(); - var getPayoutResponse = await _fixture.TlClients[0].Payouts.GetPayout(response.Data.Id); + // Extract the payout ID from the Created response + var payoutId = response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id); + + payoutId.Should().NotBeNullOrWhiteSpace(); + + var getPayoutResponse = await _fixture.TlClients[0].Payouts.GetPayout(payoutId); getPayoutResponse.StatusCode.Should().Be(HttpStatusCode.OK); getPayoutResponse.Data.Value.Should().NotBeNull(); PayoutDetails? details = getPayoutResponse.Data.Value as PayoutDetails; details.Should().NotBeNull(); - details!.Id.Should().Be(response.Data.Id); + details!.Id.Should().Be(payoutId); details.Currency.Should().Be(payoutRequest.Currency); details.Beneficiary.AsT1.Should().NotBeNull(); details.Status.Should().BeOneOf("pending", "authorized", "executed", "failed"); @@ -81,6 +106,190 @@ public async Task GetPayout_Url_As_PayoutId_Should_Throw_Exception() result.Message.Should().Be("Value is malformed (Parameter 'id')"); } + [Fact] + public async Task Can_create_verified_payout_with_name_verification() + { + // Arrange - Create a verified payout with name verification only + // Based on https://docs.truelayer.com/docs/make-a-verified-payout#test-verified-payouts-in-sandbox + // For success: Account Holder Name must be "TRANSACTION ACCOUNT 1" and use "mock" provider + var verification = new PayoutVerification(verifyName: true); + + var user = new PayoutUserRequest( + name: "John Doe", + email: "john.doe@example.com"); + + var providerSelection = new Provider.UserSelected + { + Filter = new ProviderFilter { ProviderIds = new[] { "mock" } } + }; + + var beneficiary = new Beneficiary.UserDetermined( + reference: "verified-payout-name-check", + user: user, + verification: verification, + providerSelection: providerSelection); + + var payoutRequest = new CreatePayoutRequest( + _fixture.ClientMerchantAccounts[0].GbpMerchantAccountId, + 100, + Currencies.GBP, + beneficiary, + metadata: new Dictionary { { "test", "name-verification" } }); + + // Act + var response = await _fixture.TlClients[0].Payouts.CreatePayout( + payoutRequest, + idempotencyKey: Guid.NewGuid().ToString()); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + response.Data.Should().NotBeNull(); + + // For verified payouts, we should get an AuthorizationRequired response + response.Data!.Match( + authRequired => + { + authRequired.Id.Should().NotBeNullOrWhiteSpace(); + authRequired.Status.Should().Be("authorization_required"); + authRequired.ResourceToken.Should().NotBeNullOrWhiteSpace(); + authRequired.User.Should().NotBeNull(); + authRequired.User.Id.Should().NotBeNullOrWhiteSpace(); + + // Verify we can build an HPP link + var hppLink = PayoutHppLinkBuilder.CreateVerificationLink( + authRequired, + "https://example.com/callback", + useSandbox: true); + hppLink.Should().NotBeNullOrWhiteSpace(); + hppLink.Should().Contain("app.truelayer-sandbox.com/payouts"); + hppLink.Should().Contain($"payout_id={authRequired.Id}"); + hppLink.Should().Contain($"resource_token={authRequired.ResourceToken}"); + + return true; + }, + created => throw new Exception("Expected AuthorizationRequired for verified payout, got Created")); + } + + [Fact] + public async Task Can_create_verified_payout_with_transaction_verification() + { + // Arrange - Create a verified payout with both name and transaction verification + // Based on https://docs.truelayer.com/docs/make-a-verified-payout#test-verified-payouts-in-sandbox + // For success: use tokens "18db38", "Betropolis LTD", or "LC Betropolis" + // Amount: 1000 minor, Date: 1st-7th of any month + var transactionSearchCriteria = new TransactionSearchCriteria( + tokens: new[] { "18db38", "Betropolis LTD", "LC Betropolis" }, + amountInMinor: 1000, + currency: Currencies.GBP, + createdAt: new DateTime(2024, 1, 5)); // 5th of January + + var verification = new PayoutVerification( + verifyName: true, + transactionSearchCriteria: transactionSearchCriteria); + + var user = new PayoutUserRequest( + name: "John Doe", + email: "john.doe@example.com"); + + var providerSelection = new Provider.UserSelected + { + Filter = new ProviderFilter { ProviderIds = new[] { "mock" } } + }; + + var beneficiary = new Beneficiary.UserDetermined( + reference: "verified-payout-transaction-check", + user: user, + verification: verification, + providerSelection: providerSelection); + + var payoutRequest = new CreatePayoutRequest( + _fixture.ClientMerchantAccounts[0].GbpMerchantAccountId, + 100, + Currencies.GBP, + beneficiary, + metadata: new Dictionary { { "test", "transaction-verification" } }); + + // Act + var response = await _fixture.TlClients[0].Payouts.CreatePayout( + payoutRequest, + idempotencyKey: Guid.NewGuid().ToString()); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + response.Data.Should().NotBeNull(); + + // For verified payouts, we should get an AuthorizationRequired response + response.Data!.Match( + authRequired => + { + authRequired.Id.Should().NotBeNullOrWhiteSpace(); + authRequired.Status.Should().Be("authorization_required"); + authRequired.ResourceToken.Should().NotBeNullOrWhiteSpace(); + authRequired.User.Should().NotBeNull(); + authRequired.User.Id.Should().NotBeNullOrWhiteSpace(); + + // Verify we can build an HPP link using the base method + var hppLink = PayoutHppLinkBuilder.CreateVerificationLink( + authRequired.Id, + authRequired.ResourceToken, + "https://example.com/callback", + useSandbox: true); + hppLink.Should().NotBeNullOrWhiteSpace(); + hppLink.Should().Contain("app.truelayer-sandbox.com/payouts"); + + return true; + }, + created => throw new Exception("Expected AuthorizationRequired for verified payout, got Created")); + } + + [Fact] + public async Task Can_create_verified_payout_with_preselected_provider() + { + // Arrange - Create a verified payout with preselected provider + var verification = new PayoutVerification(verifyName: true); + + var user = new PayoutUserRequest( + name: "Jane Smith", + email: "jane.smith@example.com", + phone: "+442079460087"); + + var providerSelection = new Provider.Preselected( + providerId: "mock", + schemeId: "faster_payments_service"); + + var beneficiary = new Beneficiary.UserDetermined( + reference: "verified-payout-preselected", + user: user, + verification: verification, + providerSelection: providerSelection); + + var payoutRequest = new CreatePayoutRequest( + _fixture.ClientMerchantAccounts[0].GbpMerchantAccountId, + 500, + Currencies.GBP, + beneficiary); + + // Act + var response = await _fixture.TlClients[0].Payouts.CreatePayout( + payoutRequest, + idempotencyKey: Guid.NewGuid().ToString()); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + response.Data.Should().NotBeNull(); + + response.Data!.Match( + authRequired => + { + authRequired.Id.Should().NotBeNullOrWhiteSpace(); + authRequired.Status.Should().Be("authorization_required"); + authRequired.ResourceToken.Should().NotBeNullOrWhiteSpace(); + authRequired.User.Should().NotBeNull(); + return true; + }, + created => throw new Exception("Expected AuthorizationRequired for verified payout, got Created")); + } + private CreatePayoutRequest CreatePayoutRequest() => new( _fixture.ClientMerchantAccounts[0].GbpMerchantAccountId, @@ -89,11 +298,11 @@ private CreatePayoutRequest CreatePayoutRequest() new Beneficiary.ExternalAccount( "Ms. Lucky", "truelayer-dotnet", - new AccountIdentifier.Iban("GB33BUKB20201555555555"), + new PayoutAccountIdentifier.Iban("GB33BUKB20201555555555"), 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() + schemeSelection: new PayoutSchemeSelection.InstantOnly() ); private static CreatePayoutRequest CreatePlnPayoutRequest() @@ -104,11 +313,11 @@ private static CreatePayoutRequest CreatePlnPayoutRequest() new Beneficiary.ExternalAccount( "Ms. Lucky", "truelayer-dotnet", - new AccountIdentifier.Iban("GB25CLRB04066800046876"), + new PayoutAccountIdentifier.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() + schemeSelection: new PayoutSchemeSelection.InstantOnly() ); } } diff --git a/test/TrueLayer.Tests/Payouts/PayoutsApiTests.cs b/test/TrueLayer.Tests/Payouts/PayoutsApiTests.cs index b836e77c..c0ca41c5 100644 --- a/test/TrueLayer.Tests/Payouts/PayoutsApiTests.cs +++ b/test/TrueLayer.Tests/Payouts/PayoutsApiTests.cs @@ -7,17 +7,20 @@ using OneOf; using TrueLayer.Auth; using TrueLayer.Common; +using Provider = TrueLayer.Payments.Model.Provider; using TrueLayer.Payments; using TrueLayer.Payouts; using TrueLayer.Payouts.Model; using TrueLayer.Tests.Mocks; using Xunit; using AccountIdentifier = TrueLayer.Payouts.Model.AccountIdentifier; -using Beneficiary = TrueLayer.Payouts.Model.Beneficiary; +using Beneficiary = TrueLayer.Payouts.Model.CreatePayoutBeneficiary; +using static TrueLayer.Payouts.Model.CreatePayoutResponse; namespace TrueLayer.Tests.Payouts { - using PayoutBeneficiary = OneOf; + using PayoutBeneficiary = OneOf; + using CreatePayoutUnion = OneOf; public class PayoutsApiTests { @@ -49,9 +52,9 @@ public PayoutsApiTests() [MemberData(nameof(TestData))] public async Task Generic_Successful_PayoutSource_Request(CreatePayoutRequest createPayoutRequest) { - // Arrange - var payoutResponseData = new CreatePayoutResponse("some-id"); - _apiClientMock.SetPostAsync(new ApiResponse(payoutResponseData, HttpStatusCode.OK, "trace-id")); + // Arrange - non-verified payouts only return Id + var payoutResponseData = new Created { Id = "some-id" }; + _apiClientMock.SetPostAsync(new ApiResponse(payoutResponseData, HttpStatusCode.OK, "trace-id")); var authData = new GetAuthTokenResponse("access-token", 1000, "Bearer", ""); _authApiMock.SetGetAuthToken(new ApiResponse(authData, HttpStatusCode.OK, "trace-id")); @@ -65,7 +68,14 @@ public async Task Generic_Successful_PayoutSource_Request(CreatePayoutRequest cr response.StatusCode.Should().Be(HttpStatusCode.OK); response.TraceId.Should().Be("trace-id"); response.Data.Should().NotBeNull(); - response.Data!.Id.Should().Be("some-id"); + + response.Data!.Match( + authRequired => throw new Exception("Expected Created, got AuthorizationRequired"), + created => + { + created.Id.Should().Be("some-id"); + return true; + }); } [Theory] @@ -110,6 +120,192 @@ private static CreatePayoutRequest CreatePayoutRequest(PayoutBeneficiary benefic metadata: new() { { "a", "b" } }, schemeSelection: new SchemeSelection.InstantPreferred()); + [Fact] + public async Task CreatePayout_With_Verified_Payout_Returns_AuthorizationRequired() + { + // Arrange + var verification = new Verification(verifyName: true); + var user = new PayoutUserRequest(name: "John Doe", email: "john@example.com"); + var providerSelection = new Provider.UserSelected(); + var beneficiary = new Beneficiary.UserDetermined( + reference: "verified-payout-ref", + user: user, + verification: verification, + providerSelection: providerSelection + ); + + var createPayoutRequest = new CreatePayoutRequest( + "merchant-account-id", + 100, + Currencies.GBP, + beneficiary + ); + + var payoutResponseData = new AuthorizationRequired + { + Id = "verified-payout-id", + Status = "authorization_required", + ResourceToken = "resource-token-123", + User = new PayoutUserResponse("user-id-123") + }; + + _apiClientMock.SetPostAsync(new ApiResponse(payoutResponseData, HttpStatusCode.OK, "trace-id")); + + var authData = new GetAuthTokenResponse("access-token", 1000, "Bearer", ""); + _authApiMock.SetGetAuthToken(new ApiResponse(authData, HttpStatusCode.OK, "trace-id")); + + // Act + var response = await _sut.CreatePayout(createPayoutRequest, "idempotency-key", CancellationToken.None); + + // Assert + response.Should().NotBeNull(); + response.IsSuccessful.Should().BeTrue(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.TraceId.Should().Be("trace-id"); + response.Data.Should().NotBeNull(); + + response.Data!.Match( + authRequired => + { + authRequired.Id.Should().Be("verified-payout-id"); + authRequired.Status.Should().Be("authorization_required"); + authRequired.ResourceToken.Should().Be("resource-token-123"); + authRequired.User.Should().NotBeNull(); + authRequired.User.Id.Should().Be("user-id-123"); + return true; + }, + created => throw new Exception("Expected AuthorizationRequired, got Created")); + } + + [Fact] + public void CreateVerificationLink_Should_Build_Correct_Sandbox_Url() + { + // Arrange + var payoutId = "payout-123"; + var resourceToken = "token-456"; + var returnUri = "https://example.com/callback"; + + // Act + var hppLink = PayoutHppLinkBuilder.CreateVerificationLink(payoutId, resourceToken, returnUri, useSandbox: true); + + // Assert + hppLink.Should().Be("https://app.truelayer-sandbox.com/payouts#payout_id=payout-123&resource_token=token-456&return_uri=https%3a%2f%2fexample.com%2fcallback"); + } + + [Fact] + public void CreateVerificationLink_Should_Build_Correct_Production_Url() + { + // Arrange + var payoutId = "payout-123"; + var resourceToken = "token-456"; + var returnUri = "https://example.com/callback"; + + // Act + var hppLink = PayoutHppLinkBuilder.CreateVerificationLink(payoutId, resourceToken, returnUri, useSandbox: false); + + // Assert + hppLink.Should().Be("https://app.truelayer.com/payouts#payout_id=payout-123&resource_token=token-456&return_uri=https%3a%2f%2fexample.com%2fcallback"); + } + + [Fact] + public void CreateVerificationLink_From_Response_Should_Build_Correct_Url() + { + // Arrange + var response = new AuthorizationRequired + { + Id = "payout-789", + Status = "authorization_required", + ResourceToken = "token-abc", + User = new PayoutUserResponse("user-xyz") + }; + const string returnUri = "https://example.com/callback"; + + // Act + var hppLink = PayoutHppLinkBuilder.CreateVerificationLink(response, returnUri, useSandbox: true); + + // Assert + hppLink.Should().Be("https://app.truelayer-sandbox.com/payouts#payout_id=payout-789&resource_token=token-abc&return_uri=https%3a%2f%2fexample.com%2fcallback"); + } + + [Fact] + public void CreateVerificationLink_With_Empty_ResourceToken_Should_Throw() + { + // Arrange - this test verifies the base method validation + const string payoutId = "payout-123"; + const string resourceToken = ""; + const string returnUri = "https://example.com/callback"; + + // Act & Assert + var exception = Assert.Throws(() => + PayoutHppLinkBuilder.CreateVerificationLink(payoutId, resourceToken, returnUri, useSandbox: true)); + + exception.Message.Should().Contain("Resource token cannot be null or empty"); + } + + [Fact] + public void CreateVerificationLink_With_Real_Production_Data_Should_Build_Valid_Link() + { + // Arrange - using realistic payout ID and resource token formats + const string payoutId = "c2a6c5e8-6e7f-4b3a-9d2e-1f8b3c4d5e6f"; + const string resourceToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + const string returnUri = "https://myapp.example.com/payout/callback?state=xyz123"; + + // Act + var hppLink = PayoutHppLinkBuilder.CreateVerificationLink(payoutId, resourceToken, returnUri, useSandbox: false); + + // Assert - verify the link structure is correct for production + hppLink.Should().StartWith("https://app.truelayer.com/payouts#"); + hppLink.Should().Contain($"payout_id={payoutId}"); + hppLink.Should().Contain($"resource_token={resourceToken}"); + hppLink.Should().Contain("return_uri=https%3a%2f%2fmyapp.example.com%2fpayout%2fcallback%3fstate%3dxyz123"); + + // Verify complete URL format + var expectedLink = $"https://app.truelayer.com/payouts#payout_id={payoutId}&resource_token={resourceToken}&return_uri=https%3a%2f%2fmyapp.example.com%2fpayout%2fcallback%3fstate%3dxyz123"; + hppLink.Should().Be(expectedLink); + } + + [Fact] + public void CreateVerificationLink_With_Real_Sandbox_Data_Should_Build_Valid_Link() + { + // Arrange - using realistic sandbox payout data + const string payoutId = "d3b7d6f9-7f8g-5c4b-0e3f-2g9c4d5f6g7h"; + const string resourceToken = "sandbox_token_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"; + const string returnUri = "http://localhost:3000/payout-verification-complete"; + + // Act + var hppLink = PayoutHppLinkBuilder.CreateVerificationLink(payoutId, resourceToken, returnUri, useSandbox: true); + + // Assert - verify the link structure is correct for sandbox + hppLink.Should().StartWith("https://app.truelayer-sandbox.com/payouts#"); + hppLink.Should().Contain($"payout_id={payoutId}"); + hppLink.Should().Contain($"resource_token={resourceToken}"); + hppLink.Should().Contain("return_uri=http%3a%2f%2flocalhost%3a3000%2fpayout-verification-complete"); + + // Verify complete URL format + var expectedLink = $"https://app.truelayer-sandbox.com/payouts#payout_id={payoutId}&resource_token={resourceToken}&return_uri=http%3a%2f%2flocalhost%3a3000%2fpayout-verification-complete"; + hppLink.Should().Be(expectedLink); + } + + [Fact] + public void CreateVerificationLink_With_Complex_Return_Uri_Should_Encode_Properly() + { + // Arrange - test with return URI containing special characters and query params + const string payoutId = "payout-test-123"; + const string resourceToken = "token-test-456"; + const string returnUri = "https://merchant.example.com/webhooks/payout?merchant_id=12345&session=abc-def-ghi&redirect=true&lang=en-GB"; + + // Act + var hppLink = PayoutHppLinkBuilder.CreateVerificationLink(payoutId, resourceToken, returnUri, useSandbox: true); + + // Assert - verify proper URL encoding of complex return URI + hppLink.Should().StartWith("https://app.truelayer-sandbox.com/payouts#"); + hppLink.Should().Contain("payout_id=payout-test-123"); + hppLink.Should().Contain("resource_token=token-test-456"); + + // The return_uri should be fully URL encoded + hppLink.Should().Contain("return_uri=https%3a%2f%2fmerchant.example.com%2fwebhooks%2fpayout%3fmerchant_id%3d12345%26session%3dabc-def-ghi%26redirect%3dtrue%26lang%3den-GB"); + } + public static IEnumerable TestData => new List {