diff --git a/CHANGELOG.md b/CHANGELOG.md
index d38f73495..9da341963 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/).
+## [17.5.0]
+### Added
+* Add support for Verified Payouts with user-determined beneficiary type
+* Add HPP link builder support for payouts
+
## [17.4.0] - 2025-08-07
### Added
* Add support for `scheme_id` field in merchant account transaction responses for Payout and Refund transactions
diff --git a/gradle.properties b/gradle.properties
index db67393a2..06c8870b9 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,7 +1,7 @@
# Main properties
group=com.truelayer
archivesBaseName=truelayer-java
-version=17.4.0
+version=17.5.0
# Artifacts properties
project_name=TrueLayer Java
diff --git a/src/main/java/com/truelayer/java/Environment.java b/src/main/java/com/truelayer/java/Environment.java
index d3c22e2c5..c393e101f 100644
--- a/src/main/java/com/truelayer/java/Environment.java
+++ b/src/main/java/com/truelayer/java/Environment.java
@@ -14,6 +14,8 @@ public class Environment {
private final URI hppUri;
+ private final URI hp2Uri;
+
private static final String PAYMENTS_API_DEFAULT_VERSION = "v3";
/**
@@ -24,7 +26,8 @@ public static Environment development() {
return new Environment(
URI.create("https://auth.t7r.dev"),
URI.create(MessageFormat.format("https://api.t7r.dev/{0}/", PAYMENTS_API_DEFAULT_VERSION)),
- URI.create("https://payment.t7r.dev"));
+ URI.create("https://payment.t7r.dev"),
+ URI.create("https://app.t7r.dev"));
}
/**
@@ -36,7 +39,8 @@ public static Environment sandbox() {
URI.create("https://auth.truelayer-sandbox.com"),
URI.create(
MessageFormat.format("https://api.truelayer-sandbox.com/{0}/", PAYMENTS_API_DEFAULT_VERSION)),
- URI.create("https://payment.truelayer-sandbox.com"));
+ URI.create("https://payment.truelayer-sandbox.com"),
+ URI.create("https://app.truelayer-sandbox.com"));
}
/**
@@ -47,7 +51,8 @@ public static Environment live() {
return new Environment(
URI.create("https://auth.truelayer.com"),
URI.create(MessageFormat.format("https://api.truelayer.com/{0}/", PAYMENTS_API_DEFAULT_VERSION)),
- URI.create("https://payment.truelayer.com"));
+ URI.create("https://payment.truelayer.com"),
+ URI.create("https://app.truelayer.com"));
}
/**
@@ -55,9 +60,10 @@ public static Environment live() {
* @param authApiUri the authentication API endpoint
* @param paymentsApiUri the Payments API endpoint
* @param hppUri the Hosted Payment Page endpoint
+ * @param hp2Uri the new Hosted Payment Page endpoint
* @return a custom environment object
*/
- public static Environment custom(URI authApiUri, URI paymentsApiUri, URI hppUri) {
- return new Environment(authApiUri, paymentsApiUri, hppUri);
+ public static Environment custom(URI authApiUri, URI paymentsApiUri, URI hppUri, URI hp2Uri) {
+ return new Environment(authApiUri, paymentsApiUri, hppUri, hp2Uri);
}
}
diff --git a/src/main/java/com/truelayer/java/HostedPaymentPageLinkBuilder.java b/src/main/java/com/truelayer/java/HostedPaymentPageLinkBuilder.java
index c0b3efb28..e84a77ba6 100644
--- a/src/main/java/com/truelayer/java/HostedPaymentPageLinkBuilder.java
+++ b/src/main/java/com/truelayer/java/HostedPaymentPageLinkBuilder.java
@@ -64,7 +64,7 @@ public URI build() {
URI hppLink = URI.create(MessageFormat.format(
"{0}/{1}#{2}={3}&resource_token={4}&return_uri={5}",
- environment.getHppUri(),
+ getHppLinkForResourceType(resourceType, environment),
resourceType.getHppLinkPath(),
resourceType.getHppLinkQueryParameter(),
resourceId,
@@ -81,4 +81,18 @@ public URI build() {
return hppLink;
}
+
+ private URI getHppLinkForResourceType(ResourceType resourceType, Environment environment) {
+ URI hostedPageUri;
+ switch (resourceType.getHostedPageType()) {
+ case HP2:
+ hostedPageUri = environment.getHp2Uri();
+ break;
+ case HPP:
+ default:
+ hostedPageUri = environment.getHppUri();
+ break;
+ }
+ return hostedPageUri;
+ }
}
diff --git a/src/main/java/com/truelayer/java/entities/HostedPageType.java b/src/main/java/com/truelayer/java/entities/HostedPageType.java
new file mode 100644
index 000000000..397545b51
--- /dev/null
+++ b/src/main/java/com/truelayer/java/entities/HostedPageType.java
@@ -0,0 +1,6 @@
+package com.truelayer.java.entities;
+
+public enum HostedPageType {
+ HPP,
+ HP2
+}
diff --git a/src/main/java/com/truelayer/java/entities/ResourceType.java b/src/main/java/com/truelayer/java/entities/ResourceType.java
index 626017c8c..7117272b1 100644
--- a/src/main/java/com/truelayer/java/entities/ResourceType.java
+++ b/src/main/java/com/truelayer/java/entities/ResourceType.java
@@ -1,15 +1,20 @@
package com.truelayer.java.entities;
+import static com.truelayer.java.entities.HostedPageType.HP2;
+import static com.truelayer.java.entities.HostedPageType.HPP;
+
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Getter
public enum ResourceType {
- PAYMENT("payments", "payment_id"),
- MANDATE("mandates", "mandate_id"),
+ PAYMENT("payments", "payment_id", HPP),
+ MANDATE("mandates", "mandate_id", HPP),
+ PAYOUT("payouts", "payout_id", HP2),
;
private final String hppLinkPath;
private final String hppLinkQueryParameter;
+ private final HostedPageType hostedPageType;
}
diff --git a/src/main/java/com/truelayer/java/entities/beneficiary/Beneficiary.java b/src/main/java/com/truelayer/java/entities/beneficiary/Beneficiary.java
index f970947a1..7770b73ad 100644
--- a/src/main/java/com/truelayer/java/entities/beneficiary/Beneficiary.java
+++ b/src/main/java/com/truelayer/java/entities/beneficiary/Beneficiary.java
@@ -20,7 +20,8 @@
@JsonSubTypes({
@JsonSubTypes.Type(value = ExternalAccount.class, name = "external_account"),
@JsonSubTypes.Type(value = BusinessAccount.class, name = "business_account"),
- @JsonSubTypes.Type(value = PaymentSource.class, name = "payment_source")
+ @JsonSubTypes.Type(value = PaymentSource.class, name = "payment_source"),
+ @JsonSubTypes.Type(value = UserDetermined.class, name = "user_determined")
})
@ToString
@EqualsAndHashCode
@@ -45,6 +46,11 @@ public boolean isBusinessAccount() {
return this instanceof BusinessAccount;
}
+ @JsonIgnore
+ public boolean isUserDetermined() {
+ return this instanceof UserDetermined;
+ }
+
@JsonIgnore
public BusinessAccount asBusinessAccount() {
if (!isBusinessAccount()) {
@@ -69,6 +75,14 @@ public PaymentSource asPaymentSource() {
return (PaymentSource) this;
}
+ @JsonIgnore
+ public UserDetermined asUserDetermined() {
+ if (!isUserDetermined()) {
+ throw new TrueLayerException(buildErrorMessage());
+ }
+ return (UserDetermined) this;
+ }
+
private String buildErrorMessage() {
return String.format("Beneficiary is of type %s.", this.getClass().getSimpleName());
}
@@ -78,7 +92,8 @@ private String buildErrorMessage() {
public enum Type {
EXTERNAL_ACCOUNT("external_account"),
PAYMENT_SOURCE("payment_source"),
- BUSINESS_ACCOUNT("business_account");
+ BUSINESS_ACCOUNT("business_account"),
+ USER_DETERMINED("user_determined");
@JsonValue
private final String type;
diff --git a/src/main/java/com/truelayer/java/entities/beneficiary/UserDetermined.java b/src/main/java/com/truelayer/java/entities/beneficiary/UserDetermined.java
new file mode 100644
index 000000000..4e5b92751
--- /dev/null
+++ b/src/main/java/com/truelayer/java/entities/beneficiary/UserDetermined.java
@@ -0,0 +1,26 @@
+package com.truelayer.java.entities.beneficiary;
+
+import static com.truelayer.java.entities.beneficiary.Beneficiary.Type.USER_DETERMINED;
+
+import com.truelayer.java.merchantaccounts.entities.transactions.accountidentifier.AccountIdentifier;
+import com.truelayer.java.payouts.entities.PayoutUser;
+import com.truelayer.java.payouts.entities.beneficiary.Verification;
+import java.util.List;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class UserDetermined extends Beneficiary {
+ Type type = USER_DETERMINED;
+
+ String reference;
+
+ String accountHolderName;
+
+ List accountIdentifiers;
+
+ PayoutUser user;
+
+ Verification verification;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/AuthorizationRequiredPayout.java b/src/main/java/com/truelayer/java/payouts/entities/AuthorizationRequiredPayout.java
new file mode 100644
index 000000000..77f4ac7ef
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/AuthorizationRequiredPayout.java
@@ -0,0 +1,16 @@
+package com.truelayer.java.payouts.entities;
+
+import com.truelayer.java.payouts.entities.beneficiary.Beneficiary;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+@Getter
+@ToString(callSuper = true)
+@EqualsAndHashCode(callSuper = true)
+public class AuthorizationRequiredPayout extends CreatePayoutResponse {
+ private final Status status = Status.AUTHORIZATION_REQUIRED;
+ private String resourceToken;
+ private PayoutUser user;
+ private Beneficiary beneficiary;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/AuthorizationRequiredPayoutDetail.java b/src/main/java/com/truelayer/java/payouts/entities/AuthorizationRequiredPayoutDetail.java
new file mode 100644
index 000000000..7603085a5
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/AuthorizationRequiredPayoutDetail.java
@@ -0,0 +1,10 @@
+package com.truelayer.java.payouts.entities;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class AuthorizationRequiredPayoutDetail extends Payout {
+ Status status = Status.AUTHORIZATION_REQUIRED;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/AuthorizingPayout.java b/src/main/java/com/truelayer/java/payouts/entities/AuthorizingPayout.java
new file mode 100644
index 000000000..a81e3f645
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/AuthorizingPayout.java
@@ -0,0 +1,10 @@
+package com.truelayer.java.payouts.entities;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class AuthorizingPayout extends Payout {
+ Status status = Status.AUTHORIZING;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/CreatePayoutResponse.java b/src/main/java/com/truelayer/java/payouts/entities/CreatePayoutResponse.java
index 7674a644a..923e5e47e 100644
--- a/src/main/java/com/truelayer/java/payouts/entities/CreatePayoutResponse.java
+++ b/src/main/java/com/truelayer/java/payouts/entities/CreatePayoutResponse.java
@@ -1,12 +1,63 @@
package com.truelayer.java.payouts.entities;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.truelayer.java.TrueLayerException;
import lombok.EqualsAndHashCode;
import lombok.Getter;
+import lombok.RequiredArgsConstructor;
import lombok.ToString;
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "status", defaultImpl = CreatedPayout.class)
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = CreatedPayout.class, name = "created"),
+ @JsonSubTypes.Type(value = AuthorizationRequiredPayout.class, name = "authorization_required")
+})
@ToString
@EqualsAndHashCode
@Getter
-public class CreatePayoutResponse {
+public abstract class CreatePayoutResponse {
private String id;
+
+ @JsonIgnore
+ public abstract Status getStatus();
+
+ @JsonIgnore
+ public boolean isCreated() {
+ return this instanceof CreatedPayout;
+ }
+
+ @JsonIgnore
+ public boolean isAuthorizationRequired() {
+ return this instanceof AuthorizationRequiredPayout;
+ }
+
+ @JsonIgnore
+ public CreatedPayout asCreated() {
+ if (!isCreated()) throw new TrueLayerException(buildErrorMessage());
+ return (CreatedPayout) this;
+ }
+
+ @JsonIgnore
+ public AuthorizationRequiredPayout asAuthorizationRequired() {
+ if (!isAuthorizationRequired()) throw new TrueLayerException(buildErrorMessage());
+ return (AuthorizationRequiredPayout) this;
+ }
+
+ private String buildErrorMessage() {
+ return String.format(
+ "Create payout response is of type %s.", this.getClass().getSimpleName());
+ }
+
+ @Getter
+ @RequiredArgsConstructor
+ public enum Status {
+ CREATED("created"),
+ AUTHORIZATION_REQUIRED("authorization_required");
+
+ @JsonValue
+ private final String status;
+ }
}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/CreatedPayout.java b/src/main/java/com/truelayer/java/payouts/entities/CreatedPayout.java
new file mode 100644
index 000000000..480a6cd81
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/CreatedPayout.java
@@ -0,0 +1,10 @@
+package com.truelayer.java.payouts.entities;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class CreatedPayout extends CreatePayoutResponse {
+ Status status = Status.CREATED;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/Payout.java b/src/main/java/com/truelayer/java/payouts/entities/Payout.java
index c9d957297..e701fb06f 100644
--- a/src/main/java/com/truelayer/java/payouts/entities/Payout.java
+++ b/src/main/java/com/truelayer/java/payouts/entities/Payout.java
@@ -14,6 +14,8 @@
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "status", defaultImpl = PendingPayout.class)
@JsonSubTypes({
+ @JsonSubTypes.Type(value = AuthorizationRequiredPayoutDetail.class, name = "authorization_required"),
+ @JsonSubTypes.Type(value = AuthorizingPayout.class, name = "authorizing"),
@JsonSubTypes.Type(value = PendingPayout.class, name = "pending"),
@JsonSubTypes.Type(value = AuthorizedPayout.class, name = "authorized"),
@JsonSubTypes.Type(value = ExecutedPayout.class, name = "executed"),
@@ -29,10 +31,21 @@ public abstract class Payout {
private Map metadata;
private SchemeId schemeId;
private ZonedDateTime createdAt;
+ private PayoutUser user;
@JsonIgnore
public abstract Status getStatus();
+ @JsonIgnore
+ public boolean isAuthorizationRequired() {
+ return this instanceof AuthorizationRequiredPayoutDetail;
+ }
+
+ @JsonIgnore
+ public boolean isAuthorizing() {
+ return this instanceof AuthorizingPayout;
+ }
+
@JsonIgnore
public boolean isPending() {
return this instanceof PendingPayout;
@@ -53,6 +66,18 @@ public boolean isFailed() {
return this instanceof FailedPayout;
}
+ @JsonIgnore
+ public AuthorizationRequiredPayoutDetail asAuthorizationRequiredPayout() {
+ if (!isAuthorizationRequired()) throw new TrueLayerException(buildErrorMessage());
+ return (AuthorizationRequiredPayoutDetail) this;
+ }
+
+ @JsonIgnore
+ public AuthorizingPayout asAuthorizingPayout() {
+ if (!isAuthorizing()) throw new TrueLayerException(buildErrorMessage());
+ return (AuthorizingPayout) this;
+ }
+
@JsonIgnore
public PendingPayout asPendingPayout() {
if (!isPending()) throw new TrueLayerException(buildErrorMessage());
@@ -84,6 +109,8 @@ private String buildErrorMessage() {
@Getter
@RequiredArgsConstructor
public enum Status {
+ AUTHORIZATION_REQUIRED("authorization_required"),
+ AUTHORIZING("authorizing"),
PENDING("pending"),
AUTHORIZED("authorized"),
EXECUTED("executed"),
diff --git a/src/main/java/com/truelayer/java/payouts/entities/PayoutUser.java b/src/main/java/com/truelayer/java/payouts/entities/PayoutUser.java
new file mode 100644
index 000000000..0b730c388
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/PayoutUser.java
@@ -0,0 +1,12 @@
+package com.truelayer.java.payouts.entities;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+@Getter
+@ToString
+@EqualsAndHashCode
+public class PayoutUser {
+ private String id;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/beneficiary/Beneficiary.java b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/Beneficiary.java
index 4291e77ca..ae7a13080 100644
--- a/src/main/java/com/truelayer/java/payouts/entities/beneficiary/Beneficiary.java
+++ b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/Beneficiary.java
@@ -16,7 +16,8 @@
@JsonSubTypes({
@JsonSubTypes.Type(value = ExternalAccount.class, name = "external_account"),
@JsonSubTypes.Type(value = PaymentSource.class, name = "payment_source"),
- @JsonSubTypes.Type(value = BusinessAccount.class, name = "business_account")
+ @JsonSubTypes.Type(value = BusinessAccount.class, name = "business_account"),
+ @JsonSubTypes.Type(value = UserDetermined.class, name = "user_determined")
})
public abstract class Beneficiary {
@JsonIgnore
@@ -37,6 +38,11 @@ public boolean isBusinessAccount() {
return this instanceof BusinessAccount;
}
+ @JsonIgnore
+ public boolean isUserDetermined() {
+ return this instanceof UserDetermined;
+ }
+
@JsonIgnore
public ExternalAccount asExternalAccount() {
if (!isExternalAccount()) throw new TrueLayerException(buildErrorMessage());
@@ -61,12 +67,22 @@ public static BusinessAccount.BusinessAccountBuilder businessAccount() {
return new BusinessAccount.BusinessAccountBuilder();
}
+ public static UserDetermined.UserDeterminedBuilder userDetermined() {
+ return new UserDetermined.UserDeterminedBuilder();
+ }
+
@JsonIgnore
public BusinessAccount asBusinessAccount() {
if (!isBusinessAccount()) throw new TrueLayerException(buildErrorMessage());
return (BusinessAccount) this;
}
+ @JsonIgnore
+ public UserDetermined asUserDetermined() {
+ if (!isUserDetermined()) throw new TrueLayerException(buildErrorMessage());
+ return (UserDetermined) this;
+ }
+
private String buildErrorMessage() {
return String.format("Beneficiary is of type %s.", this.getClass().getSimpleName());
}
@@ -76,7 +92,8 @@ private String buildErrorMessage() {
public enum Type {
EXTERNAL_ACCOUNT("external_account"),
PAYMENT_SOURCE("payment_source"),
- BUSINESS_ACCOUNT("business_account");
+ BUSINESS_ACCOUNT("business_account"),
+ USER_DETERMINED("user_determined");
@JsonValue
private final String type;
diff --git a/src/main/java/com/truelayer/java/payouts/entities/beneficiary/LocalDateFlexibleDeserializer.java b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/LocalDateFlexibleDeserializer.java
new file mode 100644
index 000000000..6de4cb18b
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/LocalDateFlexibleDeserializer.java
@@ -0,0 +1,33 @@
+package com.truelayer.java.payouts.entities.beneficiary;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+
+public class LocalDateFlexibleDeserializer extends JsonDeserializer {
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ private static final DateTimeFormatter TIMESTAMP_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX");
+
+ @Override
+ public LocalDate deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
+ String dateString = p.getText();
+
+ // Try to parse as date first (yyyy-MM-dd)
+ try {
+ return LocalDate.parse(dateString, DATE_FORMATTER);
+ } catch (DateTimeParseException e) {
+ // If that fails, try to parse as timestamp and extract the date part
+ try {
+ return LocalDate.parse(dateString, TIMESTAMP_FORMATTER);
+ } catch (DateTimeParseException ex) {
+ // If both fail, return epoch date as default, as this is not critical
+ return LocalDate.ofEpochDay(0);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/beneficiary/TransactionSearchCriteria.java b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/TransactionSearchCriteria.java
new file mode 100644
index 000000000..a9e1b7bb3
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/TransactionSearchCriteria.java
@@ -0,0 +1,27 @@
+package com.truelayer.java.payouts.entities.beneficiary;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.truelayer.java.entities.CurrencyCode;
+import java.time.LocalDate;
+import java.util.List;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+@Builder
+@Getter
+@ToString
+@EqualsAndHashCode
+public class TransactionSearchCriteria {
+ private List tokens;
+
+ private int amountInMinor;
+
+ private CurrencyCode currency;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
+ @JsonDeserialize(using = LocalDateFlexibleDeserializer.class)
+ private LocalDate createdAt;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/beneficiary/UserDetermined.java b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/UserDetermined.java
new file mode 100644
index 000000000..abcef3c8e
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/UserDetermined.java
@@ -0,0 +1,24 @@
+package com.truelayer.java.payouts.entities.beneficiary;
+
+import com.truelayer.java.entities.User;
+import com.truelayer.java.payouts.entities.beneficiary.providerselection.ProviderSelection;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+@Builder
+@Getter
+@ToString(callSuper = false)
+@EqualsAndHashCode(callSuper = false)
+public class UserDetermined extends Beneficiary {
+ private final Type type = Type.USER_DETERMINED;
+
+ private String reference;
+
+ private User user;
+
+ private Verification verification;
+
+ private ProviderSelection providerSelection;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/beneficiary/Verification.java b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/Verification.java
new file mode 100644
index 000000000..7a5d5b733
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/Verification.java
@@ -0,0 +1,16 @@
+package com.truelayer.java.payouts.entities.beneficiary;
+
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+@Builder
+@Getter
+@ToString
+@EqualsAndHashCode
+public class Verification {
+ private Boolean verifyName;
+
+ private TransactionSearchCriteria transactionSearchCriteria;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/PreselectedProviderSelection.java b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/PreselectedProviderSelection.java
new file mode 100644
index 000000000..ef8747a2b
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/PreselectedProviderSelection.java
@@ -0,0 +1,14 @@
+package com.truelayer.java.payouts.entities.beneficiary.providerselection;
+
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+@Getter
+@Builder
+@EqualsAndHashCode(callSuper = false)
+public class PreselectedProviderSelection extends ProviderSelection {
+ private final Type type = Type.PRESELECTED;
+
+ private String providerId;
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/ProviderSelection.java b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/ProviderSelection.java
new file mode 100644
index 000000000..5e4b822b2
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/ProviderSelection.java
@@ -0,0 +1,74 @@
+package com.truelayer.java.payouts.entities.beneficiary.providerselection;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.truelayer.java.TrueLayerException;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.ToString;
+
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = UserSelectedProviderSelection.class)
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = UserSelectedProviderSelection.class, name = "user_selected"),
+ @JsonSubTypes.Type(value = PreselectedProviderSelection.class, name = "preselected")
+})
+@ToString
+@EqualsAndHashCode
+@Getter
+public abstract class ProviderSelection {
+
+ @JsonIgnore
+ public abstract Type getType();
+
+ public static UserSelectedProviderSelection.UserSelectedProviderSelectionBuilder userSelected() {
+ return UserSelectedProviderSelection.builder();
+ }
+
+ public static PreselectedProviderSelection.PreselectedProviderSelectionBuilder preselected() {
+ return PreselectedProviderSelection.builder();
+ }
+
+ @JsonIgnore
+ public boolean isUserSelected() {
+ return this instanceof UserSelectedProviderSelection;
+ }
+
+ @JsonIgnore
+ public boolean isPreselected() {
+ return this instanceof PreselectedProviderSelection;
+ }
+
+ @JsonIgnore
+ public UserSelectedProviderSelection asUserSelected() {
+ if (!isUserSelected()) {
+ throw new TrueLayerException(buildErrorMessage());
+ }
+ return (UserSelectedProviderSelection) this;
+ }
+
+ @JsonIgnore
+ public PreselectedProviderSelection asPreselected() {
+ if (!isPreselected()) {
+ throw new TrueLayerException(buildErrorMessage());
+ }
+ return (PreselectedProviderSelection) this;
+ }
+
+ private String buildErrorMessage() {
+ return String.format(
+ "Provider selection is of type %s.", this.getClass().getSimpleName());
+ }
+
+ @RequiredArgsConstructor
+ @Getter
+ public enum Type {
+ USER_SELECTED("user_selected"),
+ PRESELECTED("preselected");
+
+ @JsonValue
+ private final String type;
+ }
+}
diff --git a/src/main/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/UserSelectedProviderSelection.java b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/UserSelectedProviderSelection.java
new file mode 100644
index 000000000..b23eaf904
--- /dev/null
+++ b/src/main/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/UserSelectedProviderSelection.java
@@ -0,0 +1,15 @@
+package com.truelayer.java.payouts.entities.beneficiary.providerselection;
+
+import com.truelayer.java.entities.ProviderFilter;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+@Builder
+@Getter
+@EqualsAndHashCode(callSuper = false)
+public class UserSelectedProviderSelection extends ProviderSelection {
+ private final Type type = Type.USER_SELECTED;
+
+ private ProviderFilter filter;
+}
diff --git a/src/test/java/com/truelayer/java/EnvironmentTests.java b/src/test/java/com/truelayer/java/EnvironmentTests.java
index 88c67f3d5..bc5cc4fd7 100644
--- a/src/test/java/com/truelayer/java/EnvironmentTests.java
+++ b/src/test/java/com/truelayer/java/EnvironmentTests.java
@@ -50,7 +50,8 @@ public void shouldCreateACustomEnvironment() {
URI customAuthUri = URI.create("http://localhost/auth");
URI customPaymentsUri = URI.create("http://localhost/pay");
URI customHppUri = URI.create("http://localhost/hpp");
- Environment environment = Environment.custom(customAuthUri, customPaymentsUri, customHppUri);
+ URI customHp2Uri = URI.create("http://localhost/hp2");
+ Environment environment = Environment.custom(customAuthUri, customPaymentsUri, customHppUri, customHp2Uri);
assertEquals(customAuthUri, environment.getAuthApiUri());
assertEquals(customPaymentsUri, environment.getPaymentsApiUri());
diff --git a/src/test/java/com/truelayer/java/HostedPaymentPageLinkBuilderTests.java b/src/test/java/com/truelayer/java/HostedPaymentPageLinkBuilderTests.java
index 092946634..faf04bd32 100644
--- a/src/test/java/com/truelayer/java/HostedPaymentPageLinkBuilderTests.java
+++ b/src/test/java/com/truelayer/java/HostedPaymentPageLinkBuilderTests.java
@@ -64,8 +64,10 @@ public void itShouldYieldAPaymentsHppLinkByDefault() {
}
@ParameterizedTest
- @DisplayName("It should yield an HPP link for a resource of type")
- @EnumSource(ResourceType.class)
+ @DisplayName("It should yield an HPP link for payments and mandates")
+ @EnumSource(
+ value = ResourceType.class,
+ names = {"PAYMENT", "MANDATE"})
public void itShouldYieldAnHppLink(ResourceType resourceType) {
Environment environment = Environment.live();
String resourceId = UUID.randomUUID().toString();
@@ -91,6 +93,36 @@ public void itShouldYieldAnHppLink(ResourceType resourceType) {
uri.toString());
}
+ @ParameterizedTest
+ @DisplayName("It should yield an HP2 link for payouts")
+ @EnumSource(
+ value = ResourceType.class,
+ names = {"PAYOUT"})
+ public void itShouldYieldAnHp2Link(ResourceType resourceType) {
+ Environment environment = Environment.live();
+ String resourceId = UUID.randomUUID().toString();
+ String resourceToken = UUID.randomUUID().toString();
+ URI returnUri = URI.create("https://example.com");
+
+ URI uri = new HostedPaymentPageLinkBuilder(environment)
+ .resourceType(resourceType)
+ .resourceId(resourceId)
+ .resourceToken(resourceToken)
+ .returnUri(returnUri)
+ .build();
+
+ assertEquals(
+ MessageFormat.format(
+ "{0}/{1}#{2}={3}&resource_token={4}&return_uri={5}",
+ environment.getHp2Uri().toString(),
+ resourceType.getHppLinkPath(),
+ resourceType.getHppLinkQueryParameter(),
+ resourceId,
+ resourceToken,
+ returnUri),
+ uri.toString());
+ }
+
@Test
@DisplayName("It should yield an HPP link with extra optional parameters")
public void itShouldYieldAPaymentsHppLinkWithExtraOptionalParameters() {
diff --git a/src/test/java/com/truelayer/java/TestUtils.java b/src/test/java/com/truelayer/java/TestUtils.java
index 1d1dac856..0cefcb4b6 100644
--- a/src/test/java/com/truelayer/java/TestUtils.java
+++ b/src/test/java/com/truelayer/java/TestUtils.java
@@ -69,7 +69,7 @@ public static VersionInfo getVersionInfo() {
}
public static Environment getTestEnvironment(URI endpointUrl) {
- return Environment.custom(endpointUrl, endpointUrl, endpointUrl);
+ return Environment.custom(endpointUrl, endpointUrl, endpointUrl, endpointUrl);
}
private TestUtils() {}
diff --git a/src/test/java/com/truelayer/java/acceptance/AcceptanceTests.java b/src/test/java/com/truelayer/java/acceptance/AcceptanceTests.java
index faf8864bf..25f7319dd 100644
--- a/src/test/java/com/truelayer/java/acceptance/AcceptanceTests.java
+++ b/src/test/java/com/truelayer/java/acceptance/AcceptanceTests.java
@@ -1,17 +1,22 @@
package com.truelayer.java.acceptance;
import static com.truelayer.java.TestUtils.assertNotError;
+import static com.truelayer.java.TestUtils.getHttpClientInstance;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import com.truelayer.java.*;
import com.truelayer.java.entities.CurrencyCode;
import com.truelayer.java.http.entities.ApiResponse;
import com.truelayer.java.merchantaccounts.entities.ListMerchantAccountsResponse;
import com.truelayer.java.merchantaccounts.entities.MerchantAccount;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import lombok.SneakyThrows;
import lombok.Synchronized;
+import okhttp3.Request;
+import okhttp3.Response;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
@@ -65,4 +70,11 @@ protected MerchantAccount getMerchantAccount(CurrencyCode currencyCode) {
return merchantAccount;
}
+
+ @SneakyThrows
+ protected void assertCanBrowseLink(URI link) {
+ Request hppRequest = new Request.Builder().url(link.toURL()).build();
+ Response hppResponse = getHttpClientInstance().newCall(hppRequest).execute();
+ assertTrue(hppResponse.isSuccessful());
+ }
}
diff --git a/src/test/java/com/truelayer/java/acceptance/PaymentsAcceptanceTests.java b/src/test/java/com/truelayer/java/acceptance/PaymentsAcceptanceTests.java
index 8743ad82b..6ae14c409 100644
--- a/src/test/java/com/truelayer/java/acceptance/PaymentsAcceptanceTests.java
+++ b/src/test/java/com/truelayer/java/acceptance/PaymentsAcceptanceTests.java
@@ -774,13 +774,6 @@ private CreatePaymentRequest buildPaymentRequest(CurrencyCode currencyCode) {
return buildPaymentRequestWithProviderSelection(userSelectionProvider, currencyCode);
}
- @SneakyThrows
- private void assertCanBrowseLink(URI link) {
- Request hppRequest = new Request.Builder().url(link.toURL()).build();
- Response hppResponse = getHttpClientInstance().newCall(hppRequest).execute();
- assertTrue(hppResponse.isSuccessful());
- }
-
// since the StartAuthorizationFlowRequest object does not support retry property
// we need to do a raw HTTP call with a JSON string request body
@SneakyThrows
diff --git a/src/test/java/com/truelayer/java/acceptance/PayoutsAcceptanceTests.java b/src/test/java/com/truelayer/java/acceptance/PayoutsAcceptanceTests.java
index c904d15b3..fd6a23ec3 100644
--- a/src/test/java/com/truelayer/java/acceptance/PayoutsAcceptanceTests.java
+++ b/src/test/java/com/truelayer/java/acceptance/PayoutsAcceptanceTests.java
@@ -4,21 +4,31 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.truelayer.java.entities.CurrencyCode;
+import com.truelayer.java.entities.ResourceType;
+import com.truelayer.java.entities.User;
import com.truelayer.java.http.entities.ApiResponse;
import com.truelayer.java.merchantaccounts.entities.MerchantAccount;
+import com.truelayer.java.payments.entities.CountryCode;
import com.truelayer.java.payouts.entities.CreatePayoutRequest;
import com.truelayer.java.payouts.entities.CreatePayoutResponse;
import com.truelayer.java.payouts.entities.Payout;
import com.truelayer.java.payouts.entities.accountidentifier.AccountIdentifier;
import com.truelayer.java.payouts.entities.beneficiary.Beneficiary;
+import com.truelayer.java.payouts.entities.beneficiary.TransactionSearchCriteria;
+import com.truelayer.java.payouts.entities.beneficiary.Verification;
+import com.truelayer.java.payouts.entities.beneficiary.providerselection.ProviderSelection;
import com.truelayer.java.payouts.entities.schemeselection.SchemeSelection;
import com.truelayer.java.payouts.entities.submerchants.BusinessClient;
import com.truelayer.java.payouts.entities.submerchants.SubMerchants;
+import java.net.URI;
+import java.time.LocalDate;
+import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Stream;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
@@ -26,10 +36,12 @@
@Tag("acceptance")
public class PayoutsAcceptanceTests extends AcceptanceTests {
- @ParameterizedTest(name = "It should create a {0} payout and get the details")
- @MethodSource("providePayoutsArgumentsForDifferentCurrencies")
+ public static final String RETURN_URI = "http://localhost:3000/callback";
+
+ @ParameterizedTest(name = "It should create a standard {0} payout and get the details")
+ @MethodSource("provideStandardPayoutsArgumentsForDifferentCurrencies")
@SneakyThrows
- public void shouldCreateAPayoutAndGetPayoutsDetails(
+ public void shouldCreateAStandardPayoutAndGetDetails(
CurrencyCode currencyCode, AccountIdentifier accountIdentifier, SchemeSelection schemeSelection) {
// find a merchant to execute the payout from
MerchantAccount merchantAccount = getMerchantAccount(currencyCode);
@@ -70,7 +82,84 @@ public void shouldCreateAPayoutAndGetPayoutsDetails(
assertEquals(merchantAccount.getId(), getPayoutResponse.getData().getMerchantAccountId());
}
- public static Stream providePayoutsArgumentsForDifferentCurrencies() {
+ @Test
+ @SneakyThrows
+ public void shouldCreateAVerifiedPayoutGetDetailsAndOpenHp2Link() {
+ // find a merchant to execute the payout from
+ MerchantAccount merchantAccount = getMerchantAccount(CurrencyCode.GBP);
+
+ int amountInMinor = ThreadLocalRandom.current().nextInt(10, 100);
+
+ LocalDate transactionCreatedAt = LocalDate.now().minusDays(7);
+
+ // create the verified payout with verification including verify name and transaction criteria
+ CreatePayoutRequest createPayoutRequest = CreatePayoutRequest.builder()
+ .merchantAccountId(merchantAccount.getId())
+ .amountInMinor(amountInMinor)
+ .currency(CurrencyCode.GBP)
+ .beneficiary(Beneficiary.userDetermined()
+ .reference("java-lib-test")
+ .user(User.builder()
+ .name("John Doe")
+ .email("john.doe@example.com")
+ .build())
+ .verification(Verification.builder()
+ .verifyName(true)
+ .transactionSearchCriteria(TransactionSearchCriteria.builder()
+ .tokens(Arrays.asList("test-token-1", "test-token-2"))
+ .amountInMinor(amountInMinor)
+ .currency(CurrencyCode.GBP)
+ .createdAt(transactionCreatedAt)
+ .build())
+ .build())
+ .providerSelection(ProviderSelection.userSelected()
+ .filter(com.truelayer.java.entities.ProviderFilter.builder()
+ .countries(Collections.singletonList(CountryCode.GB))
+ .build())
+ .build())
+ .build())
+ .metadata(Collections.singletonMap("test_type", "verified_payout"))
+ .build();
+
+ ApiResponse createPayoutResponse =
+ tlClient.payouts().createPayout(createPayoutRequest).get();
+
+ assertNotError(createPayoutResponse);
+
+ CreatePayoutResponse response = createPayoutResponse.getData();
+ assertEquals(true, response.isAuthorizationRequired());
+
+ String payoutId = response.getId();
+ String resourceToken = response.asAuthorizationRequired().getResourceToken();
+
+ // get payout details
+ ApiResponse getPayoutResponse =
+ tlClient.payouts().getPayout(payoutId).get();
+
+ assertNotError(getPayoutResponse);
+ assertEquals(payoutId, getPayoutResponse.getData().getId());
+ assertEquals(merchantAccount.getId(), getPayoutResponse.getData().getMerchantAccountId());
+ assertEquals(
+ transactionCreatedAt,
+ getPayoutResponse
+ .getData()
+ .asAuthorizationRequiredPayout()
+ .getBeneficiary()
+ .asUserDetermined()
+ .getVerification()
+ .getTransactionSearchCriteria()
+ .getCreatedAt());
+
+ URI hp2Uri = tlClient.hppLinkBuilder()
+ .resourceType(ResourceType.PAYOUT)
+ .resourceId(payoutId)
+ .resourceToken(resourceToken)
+ .returnUri(URI.create(RETURN_URI))
+ .build();
+ assertCanBrowseLink(hp2Uri);
+ }
+
+ public static Stream provideStandardPayoutsArgumentsForDifferentCurrencies() {
return Stream.of(
Arguments.of(
CurrencyCode.GBP,
diff --git a/src/test/java/com/truelayer/java/entities/beneficiary/BeneficiaryTests.java b/src/test/java/com/truelayer/java/entities/beneficiary/BeneficiaryTests.java
index 7bd0a7b34..0d6b6ef8d 100644
--- a/src/test/java/com/truelayer/java/entities/beneficiary/BeneficiaryTests.java
+++ b/src/test/java/com/truelayer/java/entities/beneficiary/BeneficiaryTests.java
@@ -122,4 +122,43 @@ public void shouldNotConvertToPaymentSource() {
assertEquals(String.format("Beneficiary is of type %s.", sut.getClass().getSimpleName()), thrown.getMessage());
}
+
+ @Test
+ @DisplayName("It should yield true if instance is of type UserDetermined")
+ public void shouldYieldTrueIfUserDetermined() {
+ Beneficiary sut = new UserDetermined(
+ "a-reference",
+ "an-account-holder-name",
+ Arrays.asList(IbanAccountIdentifier.builder().build()),
+ null,
+ null);
+
+ assertTrue(sut.isUserDetermined());
+ }
+
+ @Test
+ @DisplayName("It should convert to an instance of class UserDetermined")
+ public void shouldConvertToUserDetermined() {
+ Beneficiary sut = new UserDetermined(
+ "a-reference",
+ "an-account-holder-name",
+ Arrays.asList(IbanAccountIdentifier.builder().build()),
+ null,
+ null);
+
+ assertDoesNotThrow(sut::asUserDetermined);
+ }
+
+ @Test
+ @DisplayName("It should throw an error when converting to UserDetermined")
+ public void shouldNotConvertToUserDetermined() {
+ Beneficiary sut = new BusinessAccount(
+ "a-reference",
+ "a-name",
+ Arrays.asList(IbanAccountIdentifier.builder().build()));
+
+ Throwable thrown = assertThrows(TrueLayerException.class, sut::asUserDetermined);
+
+ assertEquals(String.format("Beneficiary is of type %s.", sut.getClass().getSimpleName()), thrown.getMessage());
+ }
}
diff --git a/src/test/java/com/truelayer/java/integration/PayoutsIntegrationTests.java b/src/test/java/com/truelayer/java/integration/PayoutsIntegrationTests.java
index 9e876ddd2..e20fed222 100644
--- a/src/test/java/com/truelayer/java/integration/PayoutsIntegrationTests.java
+++ b/src/test/java/com/truelayer/java/integration/PayoutsIntegrationTests.java
@@ -55,9 +55,41 @@ public void shouldCreateAPayout() {
assertEquals(expected, response.getData());
}
+ @Test
+ @DisplayName("It should create a payout with authorization required")
+ @SneakyThrows
+ public void shouldCreateAPayoutWithAuthorizationRequired() {
+ String jsonResponseFile = "payouts/202.create_payout.authorization_required.json";
+ RequestStub.New()
+ .method("post")
+ .path(urlPathEqualTo("/connect/token"))
+ .status(200)
+ .bodyFile("auth/200.access_token.json")
+ .build();
+ RequestStub.New()
+ .method("post")
+ .path(urlPathEqualTo("/payouts"))
+ .withAuthorization()
+ .withSignature()
+ .withIdempotencyKey()
+ .status(202)
+ .bodyFile(jsonResponseFile)
+ .build();
+ CreatePayoutRequest payoutRequest = CreatePayoutRequest.builder().build();
+
+ ApiResponse response =
+ tlClient.payouts().createPayout(payoutRequest).get();
+
+ verifyGeneratedToken(Collections.singletonList(PAYMENTS));
+ assertNotError(response);
+ CreatePayoutResponse expected = TestUtils.deserializeJsonFileTo(jsonResponseFile, CreatePayoutResponse.class);
+ assertEquals(expected, response.getData());
+ assertTrue(response.getData().isAuthorizationRequired());
+ }
+
@DisplayName("It should get payout details")
@ParameterizedTest(name = "of a payout with status {0}")
- @ValueSource(strings = {"PENDING", "AUTHORIZED", "EXECUTED", "FAILED"})
+ @ValueSource(strings = {"PENDING", "AUTHORIZED", "EXECUTED", "FAILED", "AUTHORIZATION_REQUIRED", "AUTHORIZING"})
@SneakyThrows
public void shouldReturnPayoutDetails(Payout.Status expectedStatus) {
String jsonResponseFile = "payouts/200.get_payout_by_id." + expectedStatus.getStatus() + ".json";
diff --git a/src/test/java/com/truelayer/java/payouts/entities/CreatePayoutResponseTests.java b/src/test/java/com/truelayer/java/payouts/entities/CreatePayoutResponseTests.java
new file mode 100644
index 000000000..6b1c51391
--- /dev/null
+++ b/src/test/java/com/truelayer/java/payouts/entities/CreatePayoutResponseTests.java
@@ -0,0 +1,68 @@
+package com.truelayer.java.payouts.entities;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.truelayer.java.TrueLayerException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class CreatePayoutResponseTests {
+
+ @Test
+ @DisplayName("It should yield true if instance is of type CreatedPayout")
+ public void shouldYieldTrueIfCreatedPayout() {
+ CreatePayoutResponse sut = new CreatedPayout();
+
+ assertTrue(sut.isCreated());
+ }
+
+ @Test
+ @DisplayName("It should convert to an instance of class CreatedPayout")
+ public void shouldConvertToCreatedPayout() {
+ CreatePayoutResponse sut = new CreatedPayout();
+
+ assertDoesNotThrow(sut::asCreated);
+ }
+
+ @Test
+ @DisplayName("It should throw an error when converting to CreatedPayout")
+ public void shouldNotConvertToCreatedPayout() {
+ CreatePayoutResponse sut = new AuthorizationRequiredPayout();
+
+ Throwable thrown = assertThrows(TrueLayerException.class, sut::asCreated);
+
+ assertEquals(
+ String.format(
+ "Create payout response is of type %s.", sut.getClass().getSimpleName()),
+ thrown.getMessage());
+ }
+
+ @Test
+ @DisplayName("It should yield true if instance is of type AuthorizationRequiredPayout")
+ public void shouldYieldTrueIfAuthorizationRequiredPayout() {
+ CreatePayoutResponse sut = new AuthorizationRequiredPayout();
+
+ assertTrue(sut.isAuthorizationRequired());
+ }
+
+ @Test
+ @DisplayName("It should convert to an instance of class AuthorizationRequiredPayout")
+ public void shouldConvertToAuthorizationRequiredPayout() {
+ CreatePayoutResponse sut = new AuthorizationRequiredPayout();
+
+ assertDoesNotThrow(sut::asAuthorizationRequired);
+ }
+
+ @Test
+ @DisplayName("It should throw an error when converting to AuthorizationRequiredPayout")
+ public void shouldNotConvertToAuthorizationRequiredPayout() {
+ CreatePayoutResponse sut = new CreatedPayout();
+
+ Throwable thrown = assertThrows(TrueLayerException.class, sut::asAuthorizationRequired);
+
+ assertEquals(
+ String.format(
+ "Create payout response is of type %s.", sut.getClass().getSimpleName()),
+ thrown.getMessage());
+ }
+}
diff --git a/src/test/java/com/truelayer/java/payouts/entities/PayoutTests.java b/src/test/java/com/truelayer/java/payouts/entities/PayoutTests.java
index 14ea2c2ab..3cf629e4d 100644
--- a/src/test/java/com/truelayer/java/payouts/entities/PayoutTests.java
+++ b/src/test/java/com/truelayer/java/payouts/entities/PayoutTests.java
@@ -113,4 +113,56 @@ public void shouldNotConvertToFailedPayout() {
assertEquals(String.format("Payout is of type %s.", sut.getClass().getSimpleName()), thrown.getMessage());
}
+
+ @Test
+ @DisplayName("It should yield true if instance is of type AuthorizationRequiredPayoutDetail")
+ public void shouldYieldTrueIfAuthorizationRequiredPayoutDetail() {
+ Payout sut = new AuthorizationRequiredPayoutDetail();
+
+ assertTrue(sut.isAuthorizationRequired());
+ }
+
+ @Test
+ @DisplayName("It should convert to an instance of class AuthorizationRequiredPayoutDetail")
+ public void shouldConvertToAuthorizationRequiredPayoutDetail() {
+ Payout sut = new AuthorizationRequiredPayoutDetail();
+
+ assertDoesNotThrow(sut::asAuthorizationRequiredPayout);
+ }
+
+ @Test
+ @DisplayName("It should throw an error when converting to AuthorizationRequiredPayoutDetail")
+ public void shouldNotConvertToAuthorizationRequiredPayoutDetail() {
+ Payout sut = new PendingPayout();
+
+ Throwable thrown = assertThrows(TrueLayerException.class, sut::asAuthorizationRequiredPayout);
+
+ assertEquals(String.format("Payout is of type %s.", sut.getClass().getSimpleName()), thrown.getMessage());
+ }
+
+ @Test
+ @DisplayName("It should yield true if instance is of type AuthorizingPayout")
+ public void shouldYieldTrueIfAuthorizingPayout() {
+ Payout sut = new AuthorizingPayout();
+
+ assertTrue(sut.isAuthorizing());
+ }
+
+ @Test
+ @DisplayName("It should convert to an instance of class AuthorizingPayout")
+ public void shouldConvertToAuthorizingPayout() {
+ Payout sut = new AuthorizingPayout();
+
+ assertDoesNotThrow(sut::asAuthorizingPayout);
+ }
+
+ @Test
+ @DisplayName("It should throw an error when converting to AuthorizingPayout")
+ public void shouldNotConvertToAuthorizingPayout() {
+ Payout sut = new PendingPayout();
+
+ Throwable thrown = assertThrows(TrueLayerException.class, sut::asAuthorizingPayout);
+
+ assertEquals(String.format("Payout is of type %s.", sut.getClass().getSimpleName()), thrown.getMessage());
+ }
}
diff --git a/src/test/java/com/truelayer/java/payouts/entities/beneficiary/BeneficiaryTests.java b/src/test/java/com/truelayer/java/payouts/entities/beneficiary/BeneficiaryTests.java
index 20d101405..1c18b0142 100644
--- a/src/test/java/com/truelayer/java/payouts/entities/beneficiary/BeneficiaryTests.java
+++ b/src/test/java/com/truelayer/java/payouts/entities/beneficiary/BeneficiaryTests.java
@@ -122,4 +122,42 @@ public void shouldNotConvertToPaymentSource() {
assertEquals(String.format("Beneficiary is of type %s.", sut.getClass().getSimpleName()), thrown.getMessage());
}
+
+ @Test
+ @DisplayName("It should yield true if instance is of type UserDetermined")
+ public void shouldYieldTrueIfUserDetermined() {
+ Beneficiary sut = Beneficiary.userDetermined()
+ .reference("a-reference")
+ .user(com.truelayer.java.entities.User.builder()
+ .name("John Doe")
+ .build())
+ .build();
+
+ assertTrue(sut.isUserDetermined());
+ }
+
+ @Test
+ @DisplayName("It should convert to an instance of class UserDetermined")
+ public void shouldConvertToUserDetermined() {
+ Beneficiary sut = Beneficiary.userDetermined()
+ .reference("a-reference")
+ .user(com.truelayer.java.entities.User.builder()
+ .name("John Doe")
+ .build())
+ .build();
+
+ assertDoesNotThrow(sut::asUserDetermined);
+ }
+
+ @Test
+ @DisplayName("It should throw an error when converting to UserDetermined")
+ public void shouldNotConvertToUserDetermined() {
+ Beneficiary sut = new BusinessAccount.BusinessAccountBuilder()
+ .reference("a-reference")
+ .build();
+
+ Throwable thrown = assertThrows(TrueLayerException.class, sut::asUserDetermined);
+
+ assertEquals(String.format("Beneficiary is of type %s.", sut.getClass().getSimpleName()), thrown.getMessage());
+ }
}
diff --git a/src/test/java/com/truelayer/java/payouts/entities/beneficiary/LocalDateFlexibleDeserializerTests.java b/src/test/java/com/truelayer/java/payouts/entities/beneficiary/LocalDateFlexibleDeserializerTests.java
new file mode 100644
index 000000000..c6cf33b16
--- /dev/null
+++ b/src/test/java/com/truelayer/java/payouts/entities/beneficiary/LocalDateFlexibleDeserializerTests.java
@@ -0,0 +1,76 @@
+package com.truelayer.java.payouts.entities.beneficiary;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.time.LocalDate;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+public class LocalDateFlexibleDeserializerTests {
+
+ private final LocalDateFlexibleDeserializer deserializer = new LocalDateFlexibleDeserializer();
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Test
+ @DisplayName("It should deserialize a date in yyyy-MM-dd format")
+ public void shouldDeserializeDateFormat() throws IOException {
+ String json = "\"2025-10-15\"";
+ JsonParser parser = objectMapper.getFactory().createParser(json);
+ parser.nextToken();
+
+ LocalDate result = deserializer.deserialize(parser, null);
+
+ assertEquals(LocalDate.of(2025, 10, 15), result);
+ }
+
+ @Test
+ @DisplayName("It should deserialize a timestamp in yyyy-MM-dd'T'HH:mm:ssXXX format")
+ public void shouldDeserializeTimestampFormat() throws IOException {
+ String json = "\"2025-10-15T00:00:00+00:00\"";
+ JsonParser parser = objectMapper.getFactory().createParser(json);
+ parser.nextToken();
+
+ LocalDate result = deserializer.deserialize(parser, null);
+
+ assertEquals(LocalDate.of(2025, 10, 15), result);
+ }
+
+ @Test
+ @DisplayName("It should deserialize a timestamp with different timezone")
+ public void shouldDeserializeTimestampWithTimezone() throws IOException {
+ String json = "\"2025-10-15T14:30:00+01:00\"";
+ JsonParser parser = objectMapper.getFactory().createParser(json);
+ parser.nextToken();
+
+ LocalDate result = deserializer.deserialize(parser, null);
+
+ assertEquals(LocalDate.of(2025, 10, 15), result);
+ }
+
+ @Test
+ @DisplayName("It should return epoch date when given an invalid format")
+ public void shouldReturnEpochDateForInvalidFormat() throws IOException {
+ String json = "\"invalid-date-string\"";
+ JsonParser parser = objectMapper.getFactory().createParser(json);
+ parser.nextToken();
+
+ LocalDate result = deserializer.deserialize(parser, null);
+
+ assertEquals(LocalDate.ofEpochDay(0), result);
+ }
+
+ @Test
+ @DisplayName("It should return epoch date when given an empty string")
+ public void shouldReturnEpochDateForEmptyString() throws IOException {
+ String json = "\"\"";
+ JsonParser parser = objectMapper.getFactory().createParser(json);
+ parser.nextToken();
+
+ LocalDate result = deserializer.deserialize(parser, null);
+
+ assertEquals(LocalDate.ofEpochDay(0), result);
+ }
+}
diff --git a/src/test/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/ProviderSelectionTests.java b/src/test/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/ProviderSelectionTests.java
new file mode 100644
index 000000000..40b5eae8c
--- /dev/null
+++ b/src/test/java/com/truelayer/java/payouts/entities/beneficiary/providerselection/ProviderSelectionTests.java
@@ -0,0 +1,71 @@
+package com.truelayer.java.payouts.entities.beneficiary.providerselection;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.truelayer.java.TrueLayerException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class ProviderSelectionTests {
+
+ @Test
+ @DisplayName("It should yield true if instance is of type UserSelectedProviderSelection")
+ public void shouldYieldTrueIfUserSelectedProviderSelection() {
+ ProviderSelection sut = ProviderSelection.userSelected().build();
+
+ assertTrue(sut.isUserSelected());
+ }
+
+ @Test
+ @DisplayName("It should convert to an instance of class UserSelectedProviderSelection")
+ public void shouldConvertToUserSelectedProviderSelection() {
+ ProviderSelection sut = ProviderSelection.userSelected().build();
+
+ assertDoesNotThrow(sut::asUserSelected);
+ }
+
+ @Test
+ @DisplayName("It should throw an error when converting to UserSelectedProviderSelection")
+ public void shouldNotConvertToUserSelectedProviderSelection() {
+ ProviderSelection sut =
+ ProviderSelection.preselected().providerId("a-provider-id").build();
+
+ Throwable thrown = assertThrows(TrueLayerException.class, sut::asUserSelected);
+
+ assertEquals(
+ String.format(
+ "Provider selection is of type %s.", sut.getClass().getSimpleName()),
+ thrown.getMessage());
+ }
+
+ @Test
+ @DisplayName("It should yield true if instance is of type PreselectedProviderSelection")
+ public void shouldYieldTrueIfPreselectedProviderSelection() {
+ ProviderSelection sut =
+ ProviderSelection.preselected().providerId("a-provider-id").build();
+
+ assertTrue(sut.isPreselected());
+ }
+
+ @Test
+ @DisplayName("It should convert to an instance of class PreselectedProviderSelection")
+ public void shouldConvertToPreselectedProviderSelection() {
+ ProviderSelection sut =
+ ProviderSelection.preselected().providerId("a-provider-id").build();
+
+ assertDoesNotThrow(sut::asPreselected);
+ }
+
+ @Test
+ @DisplayName("It should throw an error when converting to PreselectedProviderSelection")
+ public void shouldNotConvertToPreselectedProviderSelection() {
+ ProviderSelection sut = ProviderSelection.userSelected().build();
+
+ Throwable thrown = assertThrows(TrueLayerException.class, sut::asPreselected);
+
+ assertEquals(
+ String.format(
+ "Provider selection is of type %s.", sut.getClass().getSimpleName()),
+ thrown.getMessage());
+ }
+}
diff --git a/src/test/resources/__files/merchant_accounts/200.get_transactions.json b/src/test/resources/__files/merchant_accounts/200.get_transactions.json
index 8a8f52848..ab3cd96f7 100644
--- a/src/test/resources/__files/merchant_accounts/200.get_transactions.json
+++ b/src/test/resources/__files/merchant_accounts/200.get_transactions.json
@@ -117,24 +117,28 @@
"created_at":"2022-02-10T15:47:17.403Z",
"executed_at":"2022-02-10T15:47:17.403Z",
"beneficiary":{
- "type":"business_account",
- "merchant_account_id":"e83c4c20-b2ad-4b73-8a32-***",
+ "type":"user_determined",
+ "reference":"verified-payout-ref",
"account_holder_name":"john smith",
- "account_identifiers":[
- {
- "type":"sort_code_account_number",
- "sort_code":"040662",
- "account_number":"00002723"
- },
- {
- "type":"iban",
- "iban":"GB53CLRB04066200002723"
+ "user":{
+ "id":"user-123456"
+ },
+ "verification":{
+ "verify_name":true,
+ "transaction_search_criteria":{
+ "tokens":["token123","token456"],
+ "amount_in_minor":1,
+ "currency":"GBP",
+ "created_at":"2022-02-10T15:47:17.403Z"
}
- ]
+ }
},
"context_code":"withdrawal",
"payout_id":"string",
- "scheme_id":"internal_transfer"
+ "scheme_id":"faster_payments_service",
+ "user":{
+ "id":"user-123456"
+ }
},
{
"type":"refund",
diff --git a/src/test/resources/__files/payouts/200.get_payout_by_id.authorization_required.json b/src/test/resources/__files/payouts/200.get_payout_by_id.authorization_required.json
new file mode 100644
index 000000000..bad28441d
--- /dev/null
+++ b/src/test/resources/__files/payouts/200.get_payout_by_id.authorization_required.json
@@ -0,0 +1,28 @@
+{
+ "id": "d234567-890b-cdef-0123-***",
+ "merchant_account_id": "f84d5d31-c3be-5c84-9b43-***",
+ "amount_in_minor": 250,
+ "currency": "GBP",
+ "beneficiary": {
+ "type": "user_determined",
+ "reference": "verified-payout-ref",
+ "account_holder_name": "Jane Smith",
+ "user": {
+ "id": "user-789012"
+ },
+ "verification": {
+ "verify_name": true,
+ "transaction_search_criteria": {
+ "tokens": ["token123", "token456"],
+ "amount_in_minor": 250,
+ "currency": "GBP",
+ "created_at": "2025-10-17T16:30:00.000000Z"
+ }
+ }
+ },
+ "status": "authorization_required",
+ "user": {
+ "id": "user-789012"
+ },
+ "created_at": "2025-10-17T16:30:00.000000Z"
+}
diff --git a/src/test/resources/__files/payouts/200.get_payout_by_id.authorizing.json b/src/test/resources/__files/payouts/200.get_payout_by_id.authorizing.json
new file mode 100644
index 000000000..37f3fa065
--- /dev/null
+++ b/src/test/resources/__files/payouts/200.get_payout_by_id.authorizing.json
@@ -0,0 +1,28 @@
+{
+ "id": "e345678-901c-def0-1234-***",
+ "merchant_account_id": "f84d5d31-c3be-5c84-9b43-***",
+ "amount_in_minor": 300,
+ "currency": "GBP",
+ "beneficiary": {
+ "type": "user_determined",
+ "reference": "verified-payout-ref",
+ "account_holder_name": "John Smith",
+ "user": {
+ "id": "user-890123"
+ },
+ "verification": {
+ "verify_name": true,
+ "transaction_search_criteria": {
+ "tokens": ["token789", "token012"],
+ "amount_in_minor": 300,
+ "currency": "GBP",
+ "created_at": "2025-10-17T16:45:00.000000Z"
+ }
+ }
+ },
+ "status": "authorizing",
+ "user": {
+ "id": "user-890123"
+ },
+ "created_at": "2025-10-17T16:45:00.000000Z"
+}
diff --git a/src/test/resources/__files/payouts/202.create_payout.authorization_required.json b/src/test/resources/__files/payouts/202.create_payout.authorization_required.json
new file mode 100644
index 000000000..86c868221
--- /dev/null
+++ b/src/test/resources/__files/payouts/202.create_payout.authorization_required.json
@@ -0,0 +1,8 @@
+{
+ "id": "c123456-789a-bcde-f012-***",
+ "status": "authorization_required",
+ "resource_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
+ "user": {
+ "id": "user-123456"
+ }
+}