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" + } +}