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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ 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/).

## [18.0.0]
### Added
* Add support for Verified Payouts with user-determined beneficiary type
* Add HPP link builder support for payouts

### Changed
* ⚠️ Breaking: `CreatePayoutResponse` changed from concrete class to abstract class with polymorphic subtypes

## [17.4.0] - 2025-08-07
### Added
* Add support for `scheme_id` field in merchant account transaction responses for Payout and Refund transactions
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Main properties
group=com.truelayer
archivesBaseName=truelayer-java
version=17.4.0
version=18.0.0

# Artifacts properties
project_name=TrueLayer Java
Expand Down
16 changes: 11 additions & 5 deletions src/main/java/com/truelayer/java/Environment.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public class Environment {

private final URI hppUri;

private final URI hp2Uri;

private static final String PAYMENTS_API_DEFAULT_VERSION = "v3";

/**
Expand All @@ -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"));
}

/**
Expand All @@ -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"));
}

/**
Expand All @@ -47,17 +51,19 @@ 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"));
}

/**
* Custom environment builder. Meant for testing purposes
* @param authApiUri the authentication API endpoint
* @param paymentsApiUri the Payments API endpoint
* @param hppUri the <i>Hosted Payment Page</i> endpoint
* @param hp2Uri the new <i>Hosted Payment Page</i> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.truelayer.java.entities;

public enum HostedPageType {
HPP,
HP2
}
9 changes: 7 additions & 2 deletions src/main/java/com/truelayer/java/entities/ResourceType.java
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()) {
Expand All @@ -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());
}
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AccountIdentifier> accountIdentifiers;

PayoutUser user;

Verification verification;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Copy link
Contributor Author

@dili91 dili91 Oct 23, 2025

Choose a reason for hiding this comment

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

I'm not considering this a breaking change, because:

  • library users are not expected to instantiate CreatePayoutResponse objects their own
  • the getter for the id field is left untouched

Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 27 additions & 0 deletions src/main/java/com/truelayer/java/payouts/entities/Payout.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -29,10 +31,21 @@ public abstract class Payout {
private Map<String, String> 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;
Expand All @@ -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());
Expand Down Expand Up @@ -84,6 +109,8 @@ private String buildErrorMessage() {
@Getter
@RequiredArgsConstructor
public enum Status {
AUTHORIZATION_REQUIRED("authorization_required"),
AUTHORIZING("authorizing"),
PENDING("pending"),
AUTHORIZED("authorized"),
EXECUTED("executed"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Loading