Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthorizationRequired, Created>` 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<AuthorizationRequired, Pending, Authorized, Executed, Failed>` instead of `OneOf<Pending, Authorized, Executed, Failed>`
- 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

Expand Down
12 changes: 10 additions & 2 deletions examples/MvcExample/Controllers/PayoutController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -72,10 +74,15 @@ public async Task<IActionResult> 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);
}
Expand Down Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion src/TrueLayer/Guard.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using TrueLayer.Common;

namespace TrueLayer
{
Expand Down
2 changes: 1 addition & 1 deletion src/TrueLayer/MerchantAccounts/Model/GetTransactions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 12 additions & 6 deletions src/TrueLayer/Payouts/IPayoutsApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
>;

/// <summary>
Expand All @@ -29,7 +35,7 @@ public interface IPayoutsApi
/// </param>
/// <param name="cancellationToken">The cancellation token to cancel the operation</param>
/// <returns>An API response that includes details of the created payout if successful, otherwise problem details</returns>
Task<ApiResponse<CreatePayoutResponse>> CreatePayout(
Task<ApiResponse<CreatePayoutUnion>> CreatePayout(
CreatePayoutRequest payoutRequest,
string? idempotencyKey = null,
CancellationToken cancellationToken = default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Iban, SortCodeAccountNumber, Nrb>;

public static class Beneficiary
/// <summary>
/// Beneficiary types for CREATE payout requests
/// </summary>
public static class CreatePayoutBeneficiary
{
/// <summary>
/// Represents a TrueLayer beneficiary merchant account
Expand Down Expand Up @@ -118,17 +122,10 @@ public sealed record BusinessAccount : IDiscriminated
/// <summary>
/// Creates a new <see cref="BusinessAccount"/>.
/// </summary>
/// <param name="reference">A reference for the payout.</param>
/// <param name="accountHolderName">The business account holder name</param>
/// <param name="accountIdentifier">The unique identifier for the business account</param>
public BusinessAccount(
string reference,
string? accountHolderName = null,
AccountIdentifierUnion? accountIdentifier = null)
/// <param name="reference">A reference for the payout</param>
public BusinessAccount(string reference)
{
Reference = reference.NotNullOrWhiteSpace(nameof(reference));
AccountHolderName = accountHolderName;
AccountIdentifier = accountIdentifier;
}

/// <summary>
Expand All @@ -137,49 +134,62 @@ public BusinessAccount(
public string Type => Discriminator;

/// <summary>
/// Gets the name of the business account holder
/// </summary>
public string? AccountHolderName { get; } = "";

/// <summary>
/// Gets the reference for the business bank account holder
/// Gets the reference for the payout
/// </summary>
public string Reference { get; }

/// <summary>
/// Gets the unique scheme identifier for the business account
/// </summary>
public AccountIdentifierUnion? AccountIdentifier { get; }
}

/// <summary>
/// Represents a beneficiary that is specified by the end-user during the verification flow
/// </summary>
[JsonDiscriminator(Discriminator)]
public sealed record UserDetermined : IDiscriminated
{
const string Discriminator = "user_determined";

public UserDetermined(string reference, string accountHolderName, AccountIdentifierUnion accountIdentifier)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we already support UD?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes it was already there

This comment was marked as duplicate.

/// <summary>
/// Creates a new <see cref="UserDetermined"/> beneficiary for verified payouts
/// </summary>
/// <param name="reference">The reference for the payout, which displays in the beneficiary's bank statement</param>
/// <param name="user">Details of the beneficiary of the payment</param>
/// <param name="verification">Object that represents the verification process associated to the payout</param>
/// <param name="providerSelection">Provider Selection used for User Determined beneficiaries</param>
public UserDetermined(
string reference,
PayoutUserRequest user,
Verification verification,
OneOf<Provider.UserSelected, Provider.Preselected> 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));
}

/// <summary>
/// Gets the type of beneficiary
/// </summary>
public string Type => Discriminator;

/// <summary>
/// Gets the name of the external account holder
/// Gets the reference for the payout, which displays in the beneficiary's bank statement
/// </summary>
public string AccountHolderName { get; } = "";
public string Reference { get; }

/// <summary>
/// Gets the reference for the external bank account holder
/// Gets the user details of the beneficiary
/// </summary>
public string Reference { get; }
public PayoutUserRequest User { get; }

/// <summary>
/// Gets the unique scheme identifier for the external account
/// Gets the verification configuration
/// </summary>
public AccountIdentifierUnion AccountIdentifier { get; }
public Verification Verification { get; }

/// <summary>
/// Gets the provider selection configuration
/// </summary>
public OneOf<Provider.UserSelected, Provider.Preselected> ProviderSelection { get; }
}
}
}
4 changes: 2 additions & 2 deletions src/TrueLayer/Payouts/Model/CreatePayoutRequest.cs
Original file line number Diff line number Diff line change
@@ -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<PaymentSource, ExternalAccount, BusinessAccount>;
using BeneficiaryUnion = OneOf<PaymentSource, ExternalAccount, BusinessAccount, UserDetermined>;
using SchemeSelectionUnion = OneOf<SchemeSelection.InstantPreferred, SchemeSelection.InstantOnly, SchemeSelection.Preselected>;

/// <summary>
Expand Down
49 changes: 46 additions & 3 deletions src/TrueLayer/Payouts/Model/CreatePayoutResponse.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,51 @@
using TrueLayer.Serialization;

namespace TrueLayer.Payouts.Model
{
/// <summary>
/// Create Payout Response
/// Create Payout Response Types
/// </summary>
/// <param name="Id">The unique identifier of the payout</param>
public record CreatePayoutResponse(string Id);
public static class CreatePayoutResponse
{
/// <summary>
/// Base class containing common properties for payout creation responses
/// </summary>
public record PayoutCreated
{
/// <summary>
/// Gets the unique identifier of the payout
/// </summary>
public string Id { get; init; } = null!;
}

/// <summary>
/// Represents a verified payout that requires further authorization (user_determined beneficiary)
/// </summary>
[JsonDiscriminator("authorization_required")]
public record AuthorizationRequired : PayoutCreated
{
/// <summary>
/// Gets the status of the payout (always "authorization_required")
/// </summary>
public string Status { get; init; } = null!;

/// <summary>
/// Gets the token used to complete the payout verification via a front-end channel
/// </summary>
public string ResourceToken { get; init; } = null!;

/// <summary>
/// Gets the end user details
/// </summary>
public PayoutUserResponse User { get; init; } = null!;
}

/// <summary>
/// Represents a standard payout (external_account, payment_source, or business_account beneficiary)
/// </summary>
[DefaultJsonDiscriminator]
public record Created : PayoutCreated
{
}
}
}
Loading
Loading