From 1b9d9f340dc677fce9d928e24c83a10cc27a49b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:49:04 +0200 Subject: [PATCH 1/8] feat: update Google Pay enrollment/domain models and add new response types Refactor GooglePayClient to align with API spec, update request/response models (enrollment, domain registration), and add GooglePayEnrollmentResponse and GooglePayEnrollmentState. Includes unit, integration, and serialization tests. --- .../googlepay/GooglePayClient.java | 60 +--- .../googlepay/GooglePayClientImpl.java | 12 +- .../requests/GooglePayEnrollmentRequest.java | 20 +- .../GooglePayRegisterDomainRequest.java | 10 +- .../GooglePayDomainListResponse.java | 7 +- .../GooglePayEnrollmentResponse.java | 37 +++ .../responses/GooglePayEnrollmentState.java | 13 + .../GooglePayEnrollmentStateResponse.java | 9 +- .../googlepay/GooglePayClientImplTest.java | 18 +- .../googlepay/GooglePaySerializationTest.java | 261 ++++++++++++++++++ .../googlepay/GooglePayTestIT.java | 5 +- 11 files changed, 361 insertions(+), 91 deletions(-) create mode 100644 src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentResponse.java create mode 100644 src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentState.java create mode 100644 src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePaySerializationTest.java diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClient.java b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClient.java index 4e36a508..4c94137e 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClient.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClient.java @@ -4,81 +4,27 @@ import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayEnrollmentRequest; import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayRegisterDomainRequest; import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayDomainListResponse; +import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentResponse; import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentStateResponse; import java.util.concurrent.CompletableFuture; -/** - * Client interface for Google Pay operations. - */ public interface GooglePayClient { - /** - * Enroll an entity to the Google Pay Service. - * - * @param request the enrollment request containing entity ID, email, and terms acceptance - * @return a {@link CompletableFuture} of {@link EmptyResponse} - */ - CompletableFuture enrollEntity(GooglePayEnrollmentRequest request); + CompletableFuture enrollEntity(GooglePayEnrollmentRequest request); - /** - * Register a web domain for a Google Pay enrolled entity. - * - * @param entityId the unique identifier of the enrolled entity - * @param request the domain registration request - * @return a {@link CompletableFuture} of {@link EmptyResponse} - */ CompletableFuture registerDomain(String entityId, GooglePayRegisterDomainRequest request); - /** - * Get registered domains for a Google Pay enrolled entity. - * - * @param entityId the unique identifier of the enrolled entity - * @return a {@link CompletableFuture} of {@link GooglePayDomainListResponse} - */ CompletableFuture getRegisteredDomains(String entityId); - /** - * Get the enrollment state of a Google Pay entity. - * - * @param entityId the unique identifier of the enrolled entity - * @return a {@link CompletableFuture} of {@link GooglePayEnrollmentStateResponse} - */ CompletableFuture getEnrollmentState(String entityId); - // Synchronous methods + GooglePayEnrollmentResponse enrollEntitySync(GooglePayEnrollmentRequest request); - /** - * Enroll an entity to the Google Pay Service (synchronous version). - * - * @param request the enrollment request - * @return {@link EmptyResponse} - */ - EmptyResponse enrollEntitySync(GooglePayEnrollmentRequest request); - - /** - * Register a web domain for a Google Pay enrolled entity (synchronous version). - * - * @param entityId the unique identifier of the enrolled entity - * @param request the domain registration request - * @return {@link EmptyResponse} - */ EmptyResponse registerDomainSync(String entityId, GooglePayRegisterDomainRequest request); - /** - * Get registered domains for a Google Pay enrolled entity (synchronous version). - * - * @param entityId the unique identifier of the enrolled entity - * @return {@link GooglePayDomainListResponse} - */ GooglePayDomainListResponse getRegisteredDomainsSync(String entityId); - /** - * Get the enrollment state of a Google Pay entity (synchronous version). - * - * @param entityId the unique identifier of the enrolled entity - * @return {@link GooglePayEnrollmentStateResponse} - */ GooglePayEnrollmentStateResponse getEnrollmentStateSync(String entityId); } diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClientImpl.java b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClientImpl.java index a22ade62..1bb34f6f 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClientImpl.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClientImpl.java @@ -9,13 +9,11 @@ import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayEnrollmentRequest; import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayRegisterDomainRequest; import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayDomainListResponse; +import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentResponse; import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentStateResponse; import java.util.concurrent.CompletableFuture; -/** - * Implementation of the Google Pay client. - */ public class GooglePayClientImpl extends AbstractClient implements GooglePayClient { private static final String GOOGLEPAY_PATH = "googlepay"; @@ -29,9 +27,9 @@ public GooglePayClientImpl(final ApiClient apiClient, final CheckoutConfiguratio } @Override - public CompletableFuture enrollEntity(final GooglePayEnrollmentRequest request) { + public CompletableFuture enrollEntity(final GooglePayEnrollmentRequest request) { CheckoutUtils.validateParams("request", request); - return apiClient.postAsync(buildPath(GOOGLEPAY_PATH, ENROLLMENTS_PATH), sdkAuthorization(), EmptyResponse.class, request, null); + return apiClient.postAsync(buildPath(GOOGLEPAY_PATH, ENROLLMENTS_PATH), sdkAuthorization(), GooglePayEnrollmentResponse.class, request, null); } @Override @@ -53,9 +51,9 @@ public CompletableFuture getEnrollmentState(fi } @Override - public EmptyResponse enrollEntitySync(final GooglePayEnrollmentRequest request) { + public GooglePayEnrollmentResponse enrollEntitySync(final GooglePayEnrollmentRequest request) { CheckoutUtils.validateParams("request", request); - return apiClient.post(buildPath(GOOGLEPAY_PATH, ENROLLMENTS_PATH), sdkAuthorization(), EmptyResponse.class, request, null); + return apiClient.post(buildPath(GOOGLEPAY_PATH, ENROLLMENTS_PATH), sdkAuthorization(), GooglePayEnrollmentResponse.class, request, null); } @Override diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/requests/GooglePayEnrollmentRequest.java b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/requests/GooglePayEnrollmentRequest.java index e3e88704..2093d6e7 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/requests/GooglePayEnrollmentRequest.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/requests/GooglePayEnrollmentRequest.java @@ -6,9 +6,6 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** - * Request to enroll an entity to the Google Pay Service. - */ @Data @Builder @NoArgsConstructor @@ -17,20 +14,29 @@ public final class GooglePayEnrollmentRequest { /** * The unique identifier of the entity to enroll. + *

+ * [Required] + *

*/ - @SerializedName("entityId") + @SerializedName("entity_id") private String entityId; /** * The email address of the user accepting the Google terms of service. + *

+ * [Required] + *

*/ - @SerializedName("emailAddress") + @SerializedName("email_address") private String emailAddress; /** - * Indicates acceptance of the Google terms of service. Must be true to proceed with enrollment. + * Indicates acceptance of the Google terms of service. Must be true to proceed. + *

+ * [Required] + *

*/ - @SerializedName("acceptTermsOfService") + @SerializedName("accept_terms_of_service") private Boolean acceptTermsOfService; } diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/requests/GooglePayRegisterDomainRequest.java b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/requests/GooglePayRegisterDomainRequest.java index d3a286c4..c80caa8f 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/requests/GooglePayRegisterDomainRequest.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/requests/GooglePayRegisterDomainRequest.java @@ -1,13 +1,11 @@ package com.checkout.handlepaymentsandpayouts.googlepay.requests; +import com.google.gson.annotations.SerializedName; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -/** - * Request to register a web domain for a Google Pay enrolled entity. - */ @Data @Builder @NoArgsConstructor @@ -16,8 +14,12 @@ public final class GooglePayRegisterDomainRequest { /** * The web domain to register for an actively enrolled entity. - * Example: "some.example.com" + *

+ * [Required] + *

+ * Format: hostname */ + @SerializedName("web_domain") private String webDomain; } diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayDomainListResponse.java b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayDomainListResponse.java index 02d5f987..ed94225c 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayDomainListResponse.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayDomainListResponse.java @@ -7,9 +7,6 @@ import java.util.List; -/** - * Response containing the list of registered domains for a Google Pay enrolled entity. - */ @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @@ -17,6 +14,10 @@ public final class GooglePayDomainListResponse extends HttpMetadata { /** * The list of domains registered for the entity. + *

+ * [Required] + *

+ * Items format: hostname */ private List domains; diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentResponse.java b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentResponse.java new file mode 100644 index 00000000..283fb4a8 --- /dev/null +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentResponse.java @@ -0,0 +1,37 @@ +package com.checkout.handlepaymentsandpayouts.googlepay.responses; + +import com.checkout.HttpMetadata; +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.Instant; + +/** + * Response returned when enrolling an entity in Google Pay. + */ +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public final class GooglePayEnrollmentResponse extends HttpMetadata { + + /** + * When the Google terms of service were accepted. + *

+ * [Required] + *

+ * Format: date-time (RFC 3339) + */ + @SerializedName("tos_accepted_time") + private Instant tosAcceptedTime; + + /** + * The current enrollment state of the entity. + *

+ * [Required] + *

+ */ + private GooglePayEnrollmentState state; + +} diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentState.java b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentState.java new file mode 100644 index 00000000..84e3e9bb --- /dev/null +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentState.java @@ -0,0 +1,13 @@ +package com.checkout.handlepaymentsandpayouts.googlepay.responses; + +import com.google.gson.annotations.SerializedName; + +public enum GooglePayEnrollmentState { + + @SerializedName("ACTIVE") + ACTIVE, + + @SerializedName("INACTIVE") + INACTIVE + +} diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentStateResponse.java b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentStateResponse.java index 55c9374e..c15cf3ea 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentStateResponse.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/googlepay/responses/GooglePayEnrollmentStateResponse.java @@ -5,9 +5,6 @@ import lombok.EqualsAndHashCode; import lombok.ToString; -/** - * Response containing the enrollment state of a Google Pay entity. - */ @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @@ -15,8 +12,10 @@ public final class GooglePayEnrollmentStateResponse extends HttpMetadata { /** * The current enrollment state of the entity. - * Possible values: ACTIVE, INACTIVE + *

+ * [Required] + *

*/ - private String state; + private GooglePayEnrollmentState state; } diff --git a/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClientImplTest.java b/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClientImplTest.java index 1cf096a9..d58e0075 100644 --- a/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClientImplTest.java +++ b/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayClientImplTest.java @@ -9,6 +9,7 @@ import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayEnrollmentRequest; import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayRegisterDomainRequest; import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayDomainListResponse; +import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentResponse; import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentStateResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -55,13 +56,13 @@ void setUp() { @Test void shouldEnrollEntity() throws ExecutionException, InterruptedException { final GooglePayEnrollmentRequest request = createEnrollmentRequest(); - final EmptyResponse response = mock(EmptyResponse.class); + final GooglePayEnrollmentResponse response = mock(GooglePayEnrollmentResponse.class); when(apiClient.postAsync(eq("googlepay/enrollments"), eq(oAuthAuthorization), - eq(EmptyResponse.class), eq(request), isNull())) + eq(GooglePayEnrollmentResponse.class), eq(request), isNull())) .thenReturn(CompletableFuture.completedFuture(response)); - validateEmptyResponse(response, client.enrollEntity(request).get()); + validateEnrollmentResponse(response, client.enrollEntity(request).get()); } @Test @@ -103,13 +104,13 @@ void shouldGetEnrollmentState() throws ExecutionException, InterruptedException @Test void shouldEnrollEntitySync() { final GooglePayEnrollmentRequest request = createEnrollmentRequest(); - final EmptyResponse response = mock(EmptyResponse.class); + final GooglePayEnrollmentResponse response = mock(GooglePayEnrollmentResponse.class); when(apiClient.post(eq("googlepay/enrollments"), eq(oAuthAuthorization), - eq(EmptyResponse.class), eq(request), isNull())) + eq(GooglePayEnrollmentResponse.class), eq(request), isNull())) .thenReturn(response); - validateEmptyResponse(response, client.enrollEntitySync(request)); + validateEnrollmentResponse(response, client.enrollEntitySync(request)); } @Test @@ -156,6 +157,11 @@ private GooglePayEnrollmentRequest createEnrollmentRequest() { .build(); } + private void validateEnrollmentResponse(final GooglePayEnrollmentResponse expected, final GooglePayEnrollmentResponse actual) { + assertNotNull(actual); + assertEquals(expected, actual); + } + private void validateEmptyResponse(final EmptyResponse expected, final EmptyResponse actual) { assertNotNull(actual); assertEquals(expected, actual); diff --git a/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePaySerializationTest.java b/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePaySerializationTest.java new file mode 100644 index 00000000..6f346d47 --- /dev/null +++ b/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePaySerializationTest.java @@ -0,0 +1,261 @@ +package com.checkout.handlepaymentsandpayouts.googlepay; + +import com.checkout.GsonSerializer; +import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayEnrollmentRequest; +import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayRegisterDomainRequest; +import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayDomainListResponse; +import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentResponse; +import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentState; +import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentStateResponse; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Schema / serialization tests for Google Pay request and response DTOs. + * Validates {@link com.google.gson.annotations.SerializedName} wiring and {@link java.time.Instant} handling. + */ +class GooglePaySerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + // --- GooglePayEnrollmentRequest --- + + @Test + void shouldSerializeGooglePayEnrollmentRequestWithSnakeCaseFieldNames() { + GooglePayEnrollmentRequest request = GooglePayEnrollmentRequest.builder() + .entityId("ent_abc123") + .emailAddress("merchant@example.com") + .acceptTermsOfService(true) + .build(); + + assertDoesNotThrow(() -> { + String json = serializer.toJson(request); + assertNotNull(json); + assertTrue(json.contains("\"entity_id\""), "JSON should contain entity_id"); + assertTrue(json.contains("\"email_address\""), "JSON should contain email_address"); + assertTrue(json.contains("\"accept_terms_of_service\""), "JSON should contain accept_terms_of_service"); + assertTrue(json.contains("ent_abc123"), "JSON should contain entity id value"); + assertTrue(json.contains("merchant@example.com"), "JSON should contain email value"); + assertTrue(json.contains("true"), "JSON should contain accept_terms_of_service true"); + }); + } + + @Test + void shouldRoundTripGooglePayEnrollmentRequest() { + GooglePayEnrollmentRequest original = GooglePayEnrollmentRequest.builder() + .entityId("ent_xyz") + .emailAddress("user@shop.example.com") + .acceptTermsOfService(Boolean.TRUE) + .build(); + + String json = serializer.toJson(original); + GooglePayEnrollmentRequest deserialized = serializer.fromJson(json, GooglePayEnrollmentRequest.class); + + assertNotNull(deserialized); + assertEquals(original.getEntityId(), deserialized.getEntityId()); + assertEquals(original.getEmailAddress(), deserialized.getEmailAddress()); + assertEquals(original.getAcceptTermsOfService(), deserialized.getAcceptTermsOfService()); + } + + @Test + void shouldDeserializeSwaggerExampleGooglePayEnrollmentRequest() { + String swaggerJson = "{" + + "\"entity_id\":\"ent_swagger_1\"," + + "\"email_address\":\"owner@example.com\"," + + "\"accept_terms_of_service\":true" + + "}"; + + GooglePayEnrollmentRequest request = serializer.fromJson(swaggerJson, GooglePayEnrollmentRequest.class); + + assertNotNull(request); + assertEquals("ent_swagger_1", request.getEntityId()); + assertEquals("owner@example.com", request.getEmailAddress()); + assertEquals(Boolean.TRUE, request.getAcceptTermsOfService()); + } + + // --- GooglePayRegisterDomainRequest --- + + @Test + void shouldSerializeGooglePayRegisterDomainRequestWithWebDomainSnakeCase() { + GooglePayRegisterDomainRequest request = GooglePayRegisterDomainRequest.builder() + .webDomain("pay.example.com") + .build(); + + String json = serializer.toJson(request); + + assertNotNull(json); + assertTrue(json.contains("\"web_domain\""), "JSON should contain web_domain"); + assertTrue(json.contains("pay.example.com"), "JSON should contain domain value"); + } + + @Test + void shouldRoundTripGooglePayRegisterDomainRequest() { + GooglePayRegisterDomainRequest original = GooglePayRegisterDomainRequest.builder() + .webDomain("checkout.merchant.com") + .build(); + + String json = serializer.toJson(original); + GooglePayRegisterDomainRequest deserialized = serializer.fromJson(json, GooglePayRegisterDomainRequest.class); + + assertNotNull(deserialized); + assertEquals(original.getWebDomain(), deserialized.getWebDomain()); + } + + @Test + void shouldDeserializeSwaggerExampleGooglePayRegisterDomainRequest() { + String swaggerJson = "{\"web_domain\":\"registered.example.com\"}"; + + GooglePayRegisterDomainRequest request = serializer.fromJson(swaggerJson, GooglePayRegisterDomainRequest.class); + + assertNotNull(request); + assertEquals("registered.example.com", request.getWebDomain()); + } + + // --- GooglePayEnrollmentResponse --- + + @Test + void shouldDeserializeGooglePayEnrollmentResponseWithInactiveState() { + String json = "{" + + "\"tos_accepted_time\":\"2023-01-01T00:00:00Z\"," + + "\"state\":\"INACTIVE\"" + + "}"; + + GooglePayEnrollmentResponse response = serializer.fromJson(json, GooglePayEnrollmentResponse.class); + + assertNotNull(response); + assertEquals(Instant.parse("2023-01-01T00:00:00Z"), response.getTosAcceptedTime()); + assertEquals(GooglePayEnrollmentState.INACTIVE, response.getState()); + } + + @Test + void shouldRoundTripGooglePayEnrollmentResponse() { + GooglePayEnrollmentResponse original = new GooglePayEnrollmentResponse(); + original.setTosAcceptedTime(Instant.parse("2024-10-02T15:01:23Z")); + original.setState(GooglePayEnrollmentState.ACTIVE); + + String json = serializer.toJson(original); + GooglePayEnrollmentResponse deserialized = serializer.fromJson(json, GooglePayEnrollmentResponse.class); + + assertNotNull(deserialized); + assertEquals(original.getTosAcceptedTime(), deserialized.getTosAcceptedTime()); + assertEquals(original.getState(), deserialized.getState()); + } + + @Test + void shouldDeserializeSwaggerExampleGooglePayEnrollmentResponse() { + String swaggerJson = "{" + + "\"tos_accepted_time\":\"2024-10-02T15:01:23Z\"," + + "\"state\":\"ACTIVE\"" + + "}"; + + GooglePayEnrollmentResponse response = serializer.fromJson(swaggerJson, GooglePayEnrollmentResponse.class); + + assertNotNull(response); + assertEquals(Instant.parse("2024-10-02T15:01:23Z"), response.getTosAcceptedTime()); + assertEquals(GooglePayEnrollmentState.ACTIVE, response.getState()); + } + + // --- GooglePayEnrollmentStateResponse --- + + @Test + void shouldDeserializeGooglePayEnrollmentStateResponseActive() { + String json = "{\"state\":\"ACTIVE\"}"; + + GooglePayEnrollmentStateResponse response = serializer.fromJson(json, GooglePayEnrollmentStateResponse.class); + + assertNotNull(response); + assertEquals(GooglePayEnrollmentState.ACTIVE, response.getState()); + } + + @Test + void shouldDeserializeGooglePayEnrollmentStateResponseInactive() { + String json = "{\"state\":\"INACTIVE\"}"; + + GooglePayEnrollmentStateResponse response = serializer.fromJson(json, GooglePayEnrollmentStateResponse.class); + + assertNotNull(response); + assertEquals(GooglePayEnrollmentState.INACTIVE, response.getState()); + } + + @Test + void shouldRoundTripGooglePayEnrollmentStateResponse() { + GooglePayEnrollmentStateResponse original = new GooglePayEnrollmentStateResponse(); + original.setState(GooglePayEnrollmentState.INACTIVE); + + String json = serializer.toJson(original); + GooglePayEnrollmentStateResponse deserialized = serializer.fromJson(json, GooglePayEnrollmentStateResponse.class); + + assertNotNull(deserialized); + assertEquals(original.getState(), deserialized.getState()); + } + + // --- GooglePayDomainListResponse --- + + @Test + void shouldDeserializeGooglePayDomainListResponse() { + String json = "{\"domains\":[\"example.com\",\"shop.example.com\"]}"; + + GooglePayDomainListResponse response = serializer.fromJson(json, GooglePayDomainListResponse.class); + + assertNotNull(response); + assertNotNull(response.getDomains()); + assertEquals(2, response.getDomains().size()); + assertEquals("example.com", response.getDomains().get(0)); + assertEquals("shop.example.com", response.getDomains().get(1)); + } + + @Test + void shouldRoundTripGooglePayDomainListResponse() { + GooglePayDomainListResponse original = new GooglePayDomainListResponse(); + original.setDomains(Arrays.asList("example.com", "shop.example.com")); + + String json = serializer.toJson(original); + GooglePayDomainListResponse deserialized = serializer.fromJson(json, GooglePayDomainListResponse.class); + + assertNotNull(deserialized); + assertNotNull(deserialized.getDomains()); + assertEquals(original.getDomains(), deserialized.getDomains()); + } + + @Test + void shouldDeserializeSwaggerExampleGooglePayDomainListResponse() { + String swaggerJson = "{\"domains\":[\"example.com\",\"shop.example.com\"]}"; + + GooglePayDomainListResponse response = serializer.fromJson(swaggerJson, GooglePayDomainListResponse.class); + + assertNotNull(response); + assertEquals(Arrays.asList("example.com", "shop.example.com"), response.getDomains()); + } + + @Test + void shouldSerializeGooglePayEnrollmentRequestWithAcceptTermsFalse() { + GooglePayEnrollmentRequest request = GooglePayEnrollmentRequest.builder() + .entityId("ent_1") + .emailAddress("a@b.co") + .acceptTermsOfService(false) + .build(); + + String json = serializer.toJson(request); + assertTrue(json.contains("false")); + GooglePayEnrollmentRequest back = serializer.fromJson(json, GooglePayEnrollmentRequest.class); + assertEquals(Boolean.FALSE, back.getAcceptTermsOfService()); + } + + @Test + void shouldDeserializeGooglePayDomainListResponseEmptyList() { + String json = "{\"domains\":[]}"; + + GooglePayDomainListResponse response = serializer.fromJson(json, GooglePayDomainListResponse.class); + + assertNotNull(response); + assertNotNull(response.getDomains()); + assertTrue(response.getDomains().isEmpty()); + } +} diff --git a/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayTestIT.java b/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayTestIT.java index 685e2716..43f28893 100644 --- a/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayTestIT.java +++ b/src/test/java/com/checkout/handlepaymentsandpayouts/googlepay/GooglePayTestIT.java @@ -6,6 +6,7 @@ import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayEnrollmentRequest; import com.checkout.handlepaymentsandpayouts.googlepay.requests.GooglePayRegisterDomainRequest; import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayDomainListResponse; +import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentResponse; import com.checkout.handlepaymentsandpayouts.googlepay.responses.GooglePayEnrollmentStateResponse; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -35,7 +36,7 @@ public GooglePayTestIT() { @Test void shouldEnrollEntity() { - final EmptyResponse response = blocking(() -> + final GooglePayEnrollmentResponse response = blocking(() -> checkoutApi.googlePayClient().enrollEntity(createEnrollmentRequest())); assertNotNull(response); @@ -69,7 +70,7 @@ void shouldGetEnrollmentState() { @Test void shouldEnrollEntitySync() { - final EmptyResponse response = checkoutApi.googlePayClient().enrollEntitySync(createEnrollmentRequest()); + final GooglePayEnrollmentResponse response = checkoutApi.googlePayClient().enrollEntitySync(createEnrollmentRequest()); assertNotNull(response); } From c7b0970b204d3850de8df2d919f8eefde8ee91b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:49:25 +0200 Subject: [PATCH 2/8] feat: refactor payment methods, add PayPal support and PaymentMethodStatus Restructure payment method models (remove PaymentMethodOption/Options, add PaymentMethodStatus enum), update Bizum/Klarna/Stcpay/Tabby to align with API spec, and add PayPal payment method with shipping preference and user action enums. Includes serialization and integration tests. --- .../paymentMethods/PaymentMethods.java | 6 + .../entities/paymentMethods/bizum/Bizum.java | 12 +- .../common/PaymentMethodAction.java | 22 +- .../common/PaymentMethodBase.java | 19 +- .../common/PaymentMethodOption.java | 38 -- .../common/PaymentMethodOptions.java | 39 -- .../common/PaymentMethodStatus.java | 22 + .../paymentMethods/klarna/Klarna.java | 18 +- .../paymentMethods/paypal/PayPal.java | 51 ++ .../paypal/PayPalShippingPreference.java | 25 + .../paypal/PayPalUserAction.java | 21 + .../paymentMethods/stcpay/Stcpay.java | 19 +- .../entities/paymentMethods/tabby/Tabby.java | 19 +- .../PaymentMethodsSerializationTest.java | 548 ++++++++++++++++++ .../setups/PaymentSetupsTestIT.java | 14 - 15 files changed, 736 insertions(+), 137 deletions(-) delete mode 100644 src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodOption.java delete mode 100644 src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodOptions.java create mode 100644 src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodStatus.java create mode 100644 src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPal.java create mode 100644 src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPalShippingPreference.java create mode 100644 src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPalUserAction.java create mode 100644 src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentMethodsSerializationTest.java diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/PaymentMethods.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/PaymentMethods.java index 85d24e1e..93cb3cee 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/PaymentMethods.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/PaymentMethods.java @@ -2,6 +2,7 @@ import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.bizum.Bizum; import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.klarna.Klarna; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.paypal.PayPal; import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.stcpay.Stcpay; import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.tabby.Tabby; import lombok.AllArgsConstructor; @@ -33,6 +34,11 @@ public final class PaymentMethods { */ private Tabby tabby; + /** + * PayPal payment method configuration + */ + private PayPal paypal; + /** * Bizum payment method configuration */ diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/bizum/Bizum.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/bizum/Bizum.java index 6d4806ac..b9b5c241 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/bizum/Bizum.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/bizum/Bizum.java @@ -1,21 +1,15 @@ package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.bizum; import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodBase; -import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodOptions; -import com.google.gson.annotations.SerializedName; import lombok.Data; import lombok.EqualsAndHashCode; /** - * Bizum payment method configuration + * Bizum payment method configuration. + *

[Read-only]

+ * No method-specific fields; inherits {@link PaymentMethodBase#status status} and {@link PaymentMethodBase#flags flags} only. */ @Data @EqualsAndHashCode(callSuper = true) public final class Bizum extends PaymentMethodBase { - - /** - * Payment method options specific to Bizum - */ - @SerializedName("payment_method_options") - private PaymentMethodOptions paymentMethodOptions; } \ No newline at end of file diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodAction.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodAction.java index a06cb28f..52bea3d9 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodAction.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodAction.java @@ -1,12 +1,13 @@ package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common; +import com.google.gson.annotations.SerializedName; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** - * Payment method action configuration + * Next-step action returned by the API for a payment method (read-only). */ @Data @Builder @@ -15,17 +16,30 @@ public final class PaymentMethodAction { /** - * The type of action to be performed with the payment method + * The type of action. + *

[Read-only]

+ * {@code sdk} or {@code otp} (varies by payment method). */ private String type; /** - * The client token for payment method authentication + * The client token for the Klarna or PayPal SDK. + *

[Read-only]

*/ + @SerializedName("client_token") private String clientToken; /** - * The session identifier for the payment method session + * The session identifier for the Klarna payment method session. + *

[Read-only]

*/ + @SerializedName("session_id") private String sessionId; + + /** + * The PayPal order ID to use with the PayPal SDK. + *

[Read-only]

+ */ + @SerializedName("order_id") + private String orderId; } \ No newline at end of file diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodBase.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodBase.java index c279d67a..db991007 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodBase.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodBase.java @@ -1,31 +1,24 @@ package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common; -import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodInitialization; import lombok.Data; import java.util.List; /** - * Base class for all payment methods with common properties + * Base class for all payment methods with common properties (status and flags). */ @Data public abstract class PaymentMethodBase { /** - * The status of the payment method + * The payment method status. + *

[Read-only]

*/ - private String status; + private PaymentMethodStatus status; /** - * Configuration flags for the payment method + * An array of error codes or indicators that highlight missing or invalid information. + *

[Read-only]

*/ private List flags; - - /** - * Default: "disabled" - * The initialization state of the payment method. - * When you create a Payment Setup, this defaults to disabled. - * Enum: "disabled" "enabled" - */ - private PaymentMethodInitialization initialization = PaymentMethodInitialization.DISABLED; } \ No newline at end of file diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodOption.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodOption.java deleted file mode 100644 index b86f6e57..00000000 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodOption.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -/** - * Payment method option configuration - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public final class PaymentMethodOption { - - /** - * The unique identifier for the payment method option - */ - private String id; - - /** - * The status of the payment method option - */ - private String status; - - /** - * Configuration flags for the payment method option - */ - private List flags; - - /** - * The action configuration for the payment method option - */ - private PaymentMethodAction action; -} \ No newline at end of file diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodOptions.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodOptions.java deleted file mode 100644 index 91e1847e..00000000 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodOptions.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common; - -import com.google.gson.annotations.SerializedName; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Payment method options configuration - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public final class PaymentMethodOptions { - - /** - * Klarna SDK configuration options - */ - private PaymentMethodOption sdk; - - /** - * STC Pay full payment option configuration - */ - @SerializedName("pay_in_full") - private PaymentMethodOption payInFull; - - /** - * Tabby installments payment option configuration - */ - private PaymentMethodOption installments; - - /** - * Bizum immediate payment option configuration - */ - @SerializedName("pay_now") - private PaymentMethodOption payNow; -} \ No newline at end of file diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodStatus.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodStatus.java new file mode 100644 index 00000000..98927ebf --- /dev/null +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/common/PaymentMethodStatus.java @@ -0,0 +1,22 @@ +package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common; + +import com.google.gson.annotations.SerializedName; + +public enum PaymentMethodStatus { + + @SerializedName("unavailable") + UNAVAILABLE, + + @SerializedName("action_required") + ACTION_REQUIRED, + + @SerializedName("pending") + PENDING, + + @SerializedName("ready") + READY, + + @SerializedName("available") + AVAILABLE + +} diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/klarna/Klarna.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/klarna/Klarna.java index 5faa2ffe..3ce72ada 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/klarna/Klarna.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/klarna/Klarna.java @@ -1,27 +1,27 @@ package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.klarna; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodAction; import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodBase; -import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodOptions; -import com.google.gson.annotations.SerializedName; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodInitialization; import lombok.Data; import lombok.EqualsAndHashCode; /** - * Klarna payment method configuration + * Klarna payment method configuration. */ @Data @EqualsAndHashCode(callSuper = true) public final class Klarna extends PaymentMethodBase { /** - * The account holder information for Klarna payments + * The initialization state of the payment method. Defaults to disabled. + *

[Optional]

*/ - @SerializedName("account_holder") - private KlarnaAccountHolder accountHolder; + private PaymentMethodInitialization initialization = PaymentMethodInitialization.DISABLED; /** - * Payment method options specific to Klarna + * The next available action. Contains type {@code sdk}, {@code client_token}, {@code session_id}. + *

[Read-only]

*/ - @SerializedName("payment_method_options") - private PaymentMethodOptions paymentMethodOptions; + private PaymentMethodAction action; } \ No newline at end of file diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPal.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPal.java new file mode 100644 index 00000000..03c57f99 --- /dev/null +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPal.java @@ -0,0 +1,51 @@ +package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.paypal; + +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodAction; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodBase; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodInitialization; +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * The PayPal payment method's details and configuration. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public final class PayPal extends PaymentMethodBase { + + /** + * The initialization state of the payment method. Defaults to disabled. + *

[Optional]

+ */ + private PaymentMethodInitialization initialization = PaymentMethodInitialization.DISABLED; + + /** + * The user action for the PayPal widget. + *

[Optional]

+ */ + @SerializedName("user_action") + private PayPalUserAction userAction; + + /** + * The brand name to display in the PayPal checkout experience. + *

[Optional]

+ * Maximum length: 127 characters. + */ + @SerializedName("brand_name") + private String brandName; + + /** + * Where to obtain the shipping information. + *

[Optional]

+ */ + @SerializedName("shipping_preference") + private PayPalShippingPreference shippingPreference; + + /** + * The next available action. Contains type {@code sdk} and {@code order_id}. + *

[Read-only]

+ */ + private PaymentMethodAction action; + +} diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPalShippingPreference.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPalShippingPreference.java new file mode 100644 index 00000000..8bb564f1 --- /dev/null +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPalShippingPreference.java @@ -0,0 +1,25 @@ +package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.paypal; + +import com.google.gson.annotations.SerializedName; + +public enum PayPalShippingPreference { + + /** + * Redacts the shipping address from the PayPal pages. + */ + @SerializedName("no_shipping") + NO_SHIPPING, + + /** + * Obtains the shipping address from the customer's PayPal account. + */ + @SerializedName("get_from_file") + GET_FROM_FILE, + + /** + * Uses the shipping address provided by the merchant. + */ + @SerializedName("set_provided_address") + SET_PROVIDED_ADDRESS + +} diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPalUserAction.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPalUserAction.java new file mode 100644 index 00000000..bd721a77 --- /dev/null +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/paypal/PayPalUserAction.java @@ -0,0 +1,21 @@ +package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.paypal; + +import com.google.gson.annotations.SerializedName; + +public enum PayPalUserAction { + + /** + * After the customer clicks the PayPal button, the customer is redirected to a page to enter + * payment details, then they are immediately directed to finalize the payment. + */ + @SerializedName("pay_now") + PAY_NOW, + + /** + * After the customer clicks the PayPal button, the customer is redirected to a page to enter + * payment details and then is redirected back to the merchant's site to review and finalize the payment. + */ + @SerializedName("continue") + CONTINUE + +} diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/stcpay/Stcpay.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/stcpay/Stcpay.java index 2ebadc8c..bfa232d3 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/stcpay/Stcpay.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/stcpay/Stcpay.java @@ -1,8 +1,8 @@ package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.stcpay; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodAction; import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodBase; -import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodOptions; -import com.google.gson.annotations.SerializedName; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodInitialization; import lombok.Data; import lombok.EqualsAndHashCode; @@ -14,13 +14,20 @@ public final class Stcpay extends PaymentMethodBase { /** - * The one-time password (OTP) for STC Pay authentication + * The initialization state of the payment method. Defaults to disabled. + *

[Optional]

+ */ + private PaymentMethodInitialization initialization = PaymentMethodInitialization.DISABLED; + + /** + * The one-time password (OTP) for stc pay. + *

[Write-only]

*/ private String otp; /** - * Payment method options specific to STC Pay + * The next available action. Contains type {@code otp} when an OTP is required. + *

[Read-only]

*/ - @SerializedName("payment_method_options") - private PaymentMethodOptions paymentMethodOptions; + private PaymentMethodAction action; } \ No newline at end of file diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/tabby/Tabby.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/tabby/Tabby.java index ba542f96..18a0e2a2 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/tabby/Tabby.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/paymentMethods/tabby/Tabby.java @@ -1,21 +1,30 @@ package com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.tabby; import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodBase; -import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodOptions; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodInitialization; import com.google.gson.annotations.SerializedName; import lombok.Data; import lombok.EqualsAndHashCode; +import java.util.List; + /** - * Tabby payment method configuration + * Tabby payment method configuration. */ @Data @EqualsAndHashCode(callSuper = true) public final class Tabby extends PaymentMethodBase { /** - * Payment method options specific to Tabby + * The initialization state of the payment method. Defaults to disabled. + *

[Optional]

+ */ + private PaymentMethodInitialization initialization = PaymentMethodInitialization.DISABLED; + + /** + * The available payment types for Tabby (for example, {@code installments}). + *

[Read-only]

*/ - @SerializedName("payment_method_options") - private PaymentMethodOptions paymentMethodOptions; + @SerializedName("payment_types") + private List paymentTypes; } \ No newline at end of file diff --git a/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentMethodsSerializationTest.java b/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentMethodsSerializationTest.java new file mode 100644 index 00000000..64fe518f --- /dev/null +++ b/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentMethodsSerializationTest.java @@ -0,0 +1,548 @@ +package com.checkout.handlepaymentsandpayouts.setups; + +import com.checkout.GsonSerializer; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.PaymentMethods; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.bizum.Bizum; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodAction; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodBase; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodInitialization; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodStatus; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.klarna.Klarna; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.paypal.PayPal; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.paypal.PayPalShippingPreference; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.paypal.PayPalUserAction; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.stcpay.Stcpay; +import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.tabby.Tabby; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * GSON serialization / deserialization tests for Payment Setup {@link PaymentMethods} DTOs + * and related payment method types (aligned with swagger {@code CreatePaymentSetup.payment_methods}). + */ +class PaymentMethodsSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + @Test + void shouldDeserializeKlarnaWithAction() { + final String json = "{" + + "\"status\":\"action_required\"," + + "\"flags\":[\"missing_phone\"]," + + "\"initialization\":\"enabled\"," + + "\"action\":{" + + "\"type\":\"sdk\"," + + "\"client_token\":\"ct_klarna_abc\"," + + "\"session_id\":\"sess_111\"" + + "}" + + "}"; + + final Klarna klarna = serializer.fromJson(json, Klarna.class); + + assertNotNull(klarna); + assertEquals(PaymentMethodStatus.ACTION_REQUIRED, klarna.getStatus()); + assertEquals(Collections.singletonList("missing_phone"), klarna.getFlags()); + assertEquals(PaymentMethodInitialization.ENABLED, klarna.getInitialization()); + assertNotNull(klarna.getAction()); + assertEquals("sdk", klarna.getAction().getType()); + assertEquals("ct_klarna_abc", klarna.getAction().getClientToken()); + assertEquals("sess_111", klarna.getAction().getSessionId()); + assertNull(klarna.getAction().getOrderId()); + } + + @Test + void shouldRoundTripKlarnaWithAllProperties() { + final Klarna original = new Klarna(); + original.setStatus(PaymentMethodStatus.READY); + original.setFlags(Arrays.asList("a", "b")); + original.setInitialization(PaymentMethodInitialization.ENABLED); + original.setAction(PaymentMethodAction.builder() + .type("sdk") + .clientToken("tok") + .sessionId("sid") + .orderId(null) + .build()); + + final String json = serializer.toJson(original); + final Klarna restored = serializer.fromJson(json, Klarna.class); + + assertNotNull(restored); + assertEquals(original.getStatus(), restored.getStatus()); + assertEquals(original.getFlags(), restored.getFlags()); + assertEquals(original.getInitialization(), restored.getInitialization()); + assertNotNull(restored.getAction()); + assertEquals(original.getAction().getType(), restored.getAction().getType()); + assertEquals(original.getAction().getClientToken(), restored.getAction().getClientToken()); + assertEquals(original.getAction().getSessionId(), restored.getAction().getSessionId()); + assertEquals(original.getAction().getOrderId(), restored.getAction().getOrderId()); + } + + @Test + void shouldDeserializeStcpayWithOtpAction() { + final String json = "{" + + "\"status\":\"pending\"," + + "\"flags\":[\"stc_flag\"]," + + "\"initialization\":\"enabled\"," + + "\"otp\":\"123456\"," + + "\"action\":{\"type\":\"otp\"}" + + "}"; + + final Stcpay stcpay = serializer.fromJson(json, Stcpay.class); + + assertNotNull(stcpay); + assertEquals(PaymentMethodStatus.PENDING, stcpay.getStatus()); + assertEquals(Collections.singletonList("stc_flag"), stcpay.getFlags()); + assertEquals(PaymentMethodInitialization.ENABLED, stcpay.getInitialization()); + assertEquals("123456", stcpay.getOtp()); + assertNotNull(stcpay.getAction()); + assertEquals("otp", stcpay.getAction().getType()); + assertNull(stcpay.getAction().getClientToken()); + assertNull(stcpay.getAction().getSessionId()); + assertNull(stcpay.getAction().getOrderId()); + } + + @Test + void shouldSerializeStcpayOtp() { + final Stcpay stcpay = new Stcpay(); + stcpay.setStatus(PaymentMethodStatus.AVAILABLE); + stcpay.setFlags(Collections.emptyList()); + stcpay.setInitialization(PaymentMethodInitialization.DISABLED); + stcpay.setOtp("654321"); + + final String json = serializer.toJson(stcpay); + + assertTrue(json.contains("\"otp\"")); + assertTrue(json.contains("654321")); + } + + @Test + void shouldRoundTripStcpayWithAllProperties() { + final Stcpay original = new Stcpay(); + original.setStatus(PaymentMethodStatus.AVAILABLE); + original.setFlags(Collections.singletonList("f")); + original.setInitialization(PaymentMethodInitialization.DISABLED); + original.setOtp("111222"); + original.setAction(PaymentMethodAction.builder().type("otp").build()); + + final String json = serializer.toJson(original); + final Stcpay restored = serializer.fromJson(json, Stcpay.class); + + assertNotNull(restored); + assertEquals(original.getStatus(), restored.getStatus()); + assertEquals(original.getFlags(), restored.getFlags()); + assertEquals(original.getInitialization(), restored.getInitialization()); + assertEquals(original.getOtp(), restored.getOtp()); + assertNotNull(restored.getAction()); + assertEquals("otp", restored.getAction().getType()); + } + + @Test + void shouldDeserializeTabbyWithPaymentTypes() { + final String json = "{" + + "\"status\":\"ready\"," + + "\"flags\":[]," + + "\"initialization\":\"enabled\"," + + "\"payment_types\":[\"pay_later\"]" + + "}"; + + final Tabby tabby = serializer.fromJson(json, Tabby.class); + + assertNotNull(tabby); + assertEquals(PaymentMethodStatus.READY, tabby.getStatus()); + assertEquals(Collections.emptyList(), tabby.getFlags()); + assertEquals(PaymentMethodInitialization.ENABLED, tabby.getInitialization()); + assertEquals(Collections.singletonList("pay_later"), tabby.getPaymentTypes()); + } + + @Test + void shouldRoundTripTabbyWithAllProperties() { + final Tabby original = new Tabby(); + original.setStatus(PaymentMethodStatus.UNAVAILABLE); + original.setFlags(Arrays.asList("t1", "t2")); + original.setInitialization(PaymentMethodInitialization.DISABLED); + original.setPaymentTypes(Arrays.asList("pay_later", "installments")); + + final String json = serializer.toJson(original); + final Tabby restored = serializer.fromJson(json, Tabby.class); + + assertNotNull(restored); + assertEquals(original.getStatus(), restored.getStatus()); + assertEquals(original.getFlags(), restored.getFlags()); + assertEquals(original.getInitialization(), restored.getInitialization()); + assertEquals(original.getPaymentTypes(), restored.getPaymentTypes()); + } + + @Test + void shouldDeserializePayPalWithAction() { + final String json = "{" + + "\"status\":\"available\"," + + "\"flags\":[\"paypal_flag\"]," + + "\"initialization\":\"enabled\"," + + "\"user_action\":\"pay_now\"," + + "\"brand_name\":\"Test Brand\"," + + "\"shipping_preference\":\"no_shipping\"," + + "\"action\":{" + + "\"type\":\"sdk\"," + + "\"order_id\":\"ord_paypal_1\"" + + "}" + + "}"; + + final PayPal paypal = serializer.fromJson(json, PayPal.class); + + assertNotNull(paypal); + assertEquals(PaymentMethodStatus.AVAILABLE, paypal.getStatus()); + assertEquals(Collections.singletonList("paypal_flag"), paypal.getFlags()); + assertEquals(PaymentMethodInitialization.ENABLED, paypal.getInitialization()); + assertEquals(PayPalUserAction.PAY_NOW, paypal.getUserAction()); + assertEquals("Test Brand", paypal.getBrandName()); + assertEquals(PayPalShippingPreference.NO_SHIPPING, paypal.getShippingPreference()); + assertNotNull(paypal.getAction()); + assertEquals("sdk", paypal.getAction().getType()); + assertEquals("ord_paypal_1", paypal.getAction().getOrderId()); + assertNull(paypal.getAction().getClientToken()); + assertNull(paypal.getAction().getSessionId()); + } + + @Test + void shouldRoundTripPayPalWithAllProperties() { + final PayPal original = new PayPal(); + original.setStatus(PaymentMethodStatus.PENDING); + original.setFlags(Collections.singletonList("p")); + original.setInitialization(PaymentMethodInitialization.ENABLED); + original.setUserAction(PayPalUserAction.CONTINUE); + original.setBrandName("Acme"); + original.setShippingPreference(PayPalShippingPreference.GET_FROM_FILE); + original.setAction(PaymentMethodAction.builder() + .type("sdk") + .orderId("oid") + .clientToken(null) + .sessionId(null) + .build()); + + final String json = serializer.toJson(original); + final PayPal restored = serializer.fromJson(json, PayPal.class); + + assertNotNull(restored); + assertEquals(original.getStatus(), restored.getStatus()); + assertEquals(original.getFlags(), restored.getFlags()); + assertEquals(original.getInitialization(), restored.getInitialization()); + assertEquals(original.getUserAction(), restored.getUserAction()); + assertEquals(original.getBrandName(), restored.getBrandName()); + assertEquals(original.getShippingPreference(), restored.getShippingPreference()); + assertNotNull(restored.getAction()); + assertEquals(original.getAction().getType(), restored.getAction().getType()); + assertEquals(original.getAction().getOrderId(), restored.getAction().getOrderId()); + } + + @Test + void shouldDeserializeBizumWithoutInitialization() { + final String json = "{" + + "\"status\":\"unavailable\"," + + "\"flags\":[\"bizum_err\"]" + + "}"; + + final Bizum bizum = serializer.fromJson(json, Bizum.class); + + assertNotNull(bizum); + assertEquals(PaymentMethodStatus.UNAVAILABLE, bizum.getStatus()); + assertEquals(Collections.singletonList("bizum_err"), bizum.getFlags()); + assertFalse(hasFieldNamed(PaymentMethodBase.class, "initialization")); + assertFalse(hasFieldNamed(Bizum.class, "initialization")); + } + + @Test + void shouldRoundTripBizumWithAllProperties() { + final Bizum original = new Bizum(); + original.setStatus(PaymentMethodStatus.ACTION_REQUIRED); + original.setFlags(Arrays.asList("x", "y")); + + final String json = serializer.toJson(original); + final Bizum restored = serializer.fromJson(json, Bizum.class); + + assertNotNull(restored); + assertEquals(original.getStatus(), restored.getStatus()); + assertEquals(original.getFlags(), restored.getFlags()); + } + + @Test + void shouldSerializePaymentMethodsContainer() { + final Klarna klarna = new Klarna(); + klarna.setStatus(PaymentMethodStatus.READY); + klarna.setFlags(Collections.singletonList("k")); + klarna.setInitialization(PaymentMethodInitialization.ENABLED); + + final PayPal paypal = new PayPal(); + paypal.setStatus(PaymentMethodStatus.AVAILABLE); + paypal.setFlags(Collections.emptyList()); + paypal.setInitialization(PaymentMethodInitialization.DISABLED); + paypal.setBrandName("Shop"); + + final PaymentMethods methods = PaymentMethods.builder() + .klarna(klarna) + .paypal(paypal) + .build(); + + final String json = serializer.toJson(methods); + + assertTrue(json.contains("\"klarna\"")); + assertTrue(json.contains("\"paypal\"")); + assertTrue(json.contains("\"ready\"")); + assertTrue(json.contains("\"available\"")); + assertTrue(json.contains("Shop")); + } + + @Test + void shouldRoundTripPaymentMethodsContainer() { + final PaymentMethods original = PaymentMethods.builder() + .klarna(buildSampleKlarna()) + .stcpay(buildSampleStcpay()) + .tabby(buildSampleTabby()) + .paypal(buildSamplePayPal()) + .bizum(buildSampleBizum()) + .build(); + + final String json = serializer.toJson(original); + final PaymentMethods restored = serializer.fromJson(json, PaymentMethods.class); + + assertNotNull(restored); + assertNotNull(restored.getKlarna()); + assertNotNull(restored.getStcpay()); + assertNotNull(restored.getTabby()); + assertNotNull(restored.getPaypal()); + assertNotNull(restored.getBizum()); + + assertEquals(original.getKlarna().getStatus(), restored.getKlarna().getStatus()); + assertEquals(original.getKlarna().getFlags(), restored.getKlarna().getFlags()); + assertEquals(original.getKlarna().getInitialization(), restored.getKlarna().getInitialization()); + assertEquals(original.getKlarna().getAction().getType(), restored.getKlarna().getAction().getType()); + + assertEquals(original.getStcpay().getOtp(), restored.getStcpay().getOtp()); + assertEquals(original.getTabby().getPaymentTypes(), restored.getTabby().getPaymentTypes()); + assertEquals(original.getPaypal().getBrandName(), restored.getPaypal().getBrandName()); + assertEquals(original.getBizum().getStatus(), restored.getBizum().getStatus()); + } + + @Test + void shouldDeserializeFullPaymentMethodsFromSwaggerExample() { + final String json = "{" + + "\"klarna\":{" + + "\"status\":\"action_required\"," + + "\"flags\":[\"missing_phone\"]," + + "\"initialization\":\"enabled\"," + + "\"action\":{" + + "\"type\":\"sdk\"," + + "\"client_token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJzZXNzaW9uX2lkIiA6ICIw\"," + + "\"session_id\":\"0b1d9815-165e-42e2-8867-35bc03789e00\"" + + "}" + + "}," + + "\"stcpay\":{" + + "\"status\":\"pending\"," + + "\"flags\":[]," + + "\"initialization\":\"disabled\"," + + "\"action\":{\"type\":\"otp\"}" + + "}," + + "\"tabby\":{" + + "\"status\":\"ready\"," + + "\"flags\":[]," + + "\"initialization\":\"enabled\"," + + "\"payment_types\":[\"pay_later\"]" + + "}," + + "\"paypal\":{" + + "\"status\":\"available\"," + + "\"flags\":[]," + + "\"initialization\":\"enabled\"," + + "\"user_action\":\"pay_now\"," + + "\"brand_name\":\"Acme Corporation\"," + + "\"shipping_preference\":\"no_shipping\"," + + "\"action\":{" + + "\"type\":\"sdk\"," + + "\"order_id\":\"dd4b1a85-31de-404b-99f4-f143f8ba35ba\"" + + "}" + + "}," + + "\"bizum\":{" + + "\"status\":\"unavailable\"," + + "\"flags\":[\"bizum_setup_incomplete\"]" + + "}" + + "}"; + + final PaymentMethods methods = serializer.fromJson(json, PaymentMethods.class); + + assertNotNull(methods.getKlarna()); + assertEquals("sdk", methods.getKlarna().getAction().getType()); + assertEquals("0b1d9815-165e-42e2-8867-35bc03789e00", methods.getKlarna().getAction().getSessionId()); + + assertNotNull(methods.getStcpay()); + assertEquals("otp", methods.getStcpay().getAction().getType()); + + assertNotNull(methods.getTabby()); + assertEquals(Collections.singletonList("pay_later"), methods.getTabby().getPaymentTypes()); + + assertNotNull(methods.getPaypal()); + assertEquals(PayPalUserAction.PAY_NOW, methods.getPaypal().getUserAction()); + assertEquals("Acme Corporation", methods.getPaypal().getBrandName()); + assertEquals("dd4b1a85-31de-404b-99f4-f143f8ba35ba", methods.getPaypal().getAction().getOrderId()); + + assertNotNull(methods.getBizum()); + assertEquals(PaymentMethodStatus.UNAVAILABLE, methods.getBizum().getStatus()); + } + + @Test + void shouldSerializePayPalUserAction() { + final PayPal payNow = new PayPal(); + payNow.setUserAction(PayPalUserAction.PAY_NOW); + assertTrue(serializer.toJson(payNow).contains("\"pay_now\"")); + + final PayPal cont = new PayPal(); + cont.setUserAction(PayPalUserAction.CONTINUE); + assertTrue(serializer.toJson(cont).contains("\"continue\"")); + } + + @Test + void shouldSerializePayPalShippingPreference() { + final PayPal noShipping = new PayPal(); + noShipping.setShippingPreference(PayPalShippingPreference.NO_SHIPPING); + assertTrue(serializer.toJson(noShipping).contains("\"no_shipping\"")); + + final PayPal fromFile = new PayPal(); + fromFile.setShippingPreference(PayPalShippingPreference.GET_FROM_FILE); + assertTrue(serializer.toJson(fromFile).contains("\"get_from_file\"")); + + final PayPal provided = new PayPal(); + provided.setShippingPreference(PayPalShippingPreference.SET_PROVIDED_ADDRESS); + assertTrue(serializer.toJson(provided).contains("\"set_provided_address\"")); + } + + @Test + void shouldRoundTripPaymentMethodActionWithAllProperties() { + final PaymentMethodAction original = PaymentMethodAction.builder() + .type("sdk") + .clientToken("ct_all") + .sessionId("sid_all") + .orderId("ord_all") + .build(); + + final String json = serializer.toJson(original); + final PaymentMethodAction restored = serializer.fromJson(json, PaymentMethodAction.class); + + assertNotNull(restored); + assertEquals(original.getType(), restored.getType()); + assertEquals(original.getClientToken(), restored.getClientToken()); + assertEquals(original.getSessionId(), restored.getSessionId()); + assertEquals(original.getOrderId(), restored.getOrderId()); + assertTrue(json.contains("\"client_token\"")); + assertTrue(json.contains("\"session_id\"")); + assertTrue(json.contains("\"order_id\"")); + } + + @Test + void shouldDeserializeAllPaymentMethodStatusWireValues() { + assertEquals(PaymentMethodStatus.UNAVAILABLE, serializer.fromJson("{\"status\":\"unavailable\"}", Klarna.class).getStatus()); + assertEquals(PaymentMethodStatus.ACTION_REQUIRED, serializer.fromJson("{\"status\":\"action_required\"}", Klarna.class).getStatus()); + assertEquals(PaymentMethodStatus.PENDING, serializer.fromJson("{\"status\":\"pending\"}", Klarna.class).getStatus()); + assertEquals(PaymentMethodStatus.READY, serializer.fromJson("{\"status\":\"ready\"}", Klarna.class).getStatus()); + assertEquals(PaymentMethodStatus.AVAILABLE, serializer.fromJson("{\"status\":\"available\"}", Klarna.class).getStatus()); + } + + @Test + void shouldSerializeAllPaymentMethodStatusValues() { + for (final PaymentMethodStatus status : PaymentMethodStatus.values()) { + final Klarna k = new Klarna(); + k.setStatus(status); + final String json = serializer.toJson(k); + switch (status) { + case UNAVAILABLE: + assertTrue(json.contains("\"unavailable\"")); + break; + case ACTION_REQUIRED: + assertTrue(json.contains("\"action_required\"")); + break; + case PENDING: + assertTrue(json.contains("\"pending\"")); + break; + case READY: + assertTrue(json.contains("\"ready\"")); + break; + case AVAILABLE: + assertTrue(json.contains("\"available\"")); + break; + default: + throw new IllegalStateException("Unhandled status: " + status); + } + } + } + + @Test + void shouldRoundTripPaymentMethodInitialization() { + for (final PaymentMethodInitialization init : PaymentMethodInitialization.values()) { + final Klarna k = new Klarna(); + k.setInitialization(init); + final Klarna restored = serializer.fromJson(serializer.toJson(k), Klarna.class); + assertEquals(init, restored.getInitialization()); + } + } + + private static Klarna buildSampleKlarna() { + final Klarna k = new Klarna(); + k.setStatus(PaymentMethodStatus.READY); + k.setFlags(Collections.singletonList("kf")); + k.setInitialization(PaymentMethodInitialization.ENABLED); + k.setAction(PaymentMethodAction.builder().type("sdk").clientToken("c").sessionId("s").build()); + return k; + } + + private static Stcpay buildSampleStcpay() { + final Stcpay s = new Stcpay(); + s.setStatus(PaymentMethodStatus.PENDING); + s.setFlags(Collections.emptyList()); + s.setInitialization(PaymentMethodInitialization.DISABLED); + s.setOtp("999888"); + s.setAction(PaymentMethodAction.builder().type("otp").build()); + return s; + } + + private static Tabby buildSampleTabby() { + final Tabby t = new Tabby(); + t.setStatus(PaymentMethodStatus.AVAILABLE); + t.setFlags(Collections.singletonList("tf")); + t.setInitialization(PaymentMethodInitialization.ENABLED); + t.setPaymentTypes(Collections.singletonList("pay_later")); + return t; + } + + private static PayPal buildSamplePayPal() { + final PayPal p = new PayPal(); + p.setStatus(PaymentMethodStatus.ACTION_REQUIRED); + p.setFlags(Collections.singletonList("pf")); + p.setInitialization(PaymentMethodInitialization.DISABLED); + p.setUserAction(PayPalUserAction.PAY_NOW); + p.setBrandName("Brand"); + p.setShippingPreference(PayPalShippingPreference.SET_PROVIDED_ADDRESS); + p.setAction(PaymentMethodAction.builder().type("sdk").orderId("o1").build()); + return p; + } + + private static Bizum buildSampleBizum() { + final Bizum b = new Bizum(); + b.setStatus(PaymentMethodStatus.UNAVAILABLE); + b.setFlags(Arrays.asList("b1", "b2")); + return b; + } + + private static boolean hasFieldNamed(final Class clazz, final String name) { + for (final Field f : clazz.getDeclaredFields()) { + if (name.equals(f.getName())) { + return true; + } + } + return false; + } +} diff --git a/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsTestIT.java b/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsTestIT.java index 72199533..9339e244 100644 --- a/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsTestIT.java +++ b/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsTestIT.java @@ -2,8 +2,6 @@ import com.checkout.PlatformType; import com.checkout.SandboxTestFixture; -import com.checkout.common.Address; -import com.checkout.common.CountryCode; import com.checkout.common.Currency; import com.checkout.common.Phone; import com.checkout.handlepaymentsandpayouts.setups.entities.customer.Customer; @@ -12,7 +10,6 @@ import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.PaymentMethods; import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.common.PaymentMethodInitialization; import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.klarna.Klarna; -import com.checkout.handlepaymentsandpayouts.setups.entities.paymentMethods.klarna.KlarnaAccountHolder; import com.checkout.handlepaymentsandpayouts.setups.entities.settings.Settings; import com.checkout.payments.PaymentType; import com.checkout.handlepaymentsandpayouts.setups.requests.PaymentSetupsRequest; @@ -129,17 +126,6 @@ void confirmPaymentSetup_ShouldReturnValidResponse() { private PaymentSetupsRequest createValidPaymentSetupsRequest() { final Klarna klarna = new Klarna(); klarna.setInitialization(PaymentMethodInitialization.DISABLED); - - final KlarnaAccountHolder accountHolder = KlarnaAccountHolder.builder() - .billingAddress(Address.builder() - .addressLine1("123 High Street") - .city("London") - .zip("SW1A 1AA") - .country(CountryCode.GB) - .build()) - .build(); - - klarna.setAccountHolder(accountHolder); return PaymentSetupsRequest.builder() .processingChannelId(System.getenv("CHECKOUT_PROCESSING_CHANNEL_ID")) From d1e9eb7138d6bc04294a647181e8f927b39bd765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:49:37 +0200 Subject: [PATCH 3/8] feat: add new fields to issuing card create requests Add metadata, purpose, and additional properties to CardRequest, PhysicalCardRequest, and VirtualCardRequest. Includes unit, schema, and integration tests for the new fields. --- .../cards/requests/create/CardRequest.java | 21 +++ .../requests/create/PhysicalCardRequest.java | 9 +- .../requests/create/VirtualCardRequest.java | 9 +- .../newfields/IssuingCardNewFieldsIT.java | 128 +++++++++++++ .../IssuingCardNewFieldsSchemaTest.java | 176 ++++++++++++++++++ .../IssuingCardNewFieldsUnitTest.java | 159 ++++++++++++++++ 6 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsIT.java create mode 100644 src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsSchemaTest.java create mode 100644 src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsUnitTest.java diff --git a/src/main/java/com/checkout/issuing/cards/requests/create/CardRequest.java b/src/main/java/com/checkout/issuing/cards/requests/create/CardRequest.java index 2a137240..fc0a747e 100644 --- a/src/main/java/com/checkout/issuing/cards/requests/create/CardRequest.java +++ b/src/main/java/com/checkout/issuing/cards/requests/create/CardRequest.java @@ -1,9 +1,12 @@ package com.checkout.issuing.cards.requests.create; +import com.checkout.issuing.cards.requests.update.IssuingCardMetadata; import com.checkout.issuing.cards.CardType; import com.google.gson.annotations.SerializedName; import lombok.Data; +import java.time.LocalDate; + @Data public abstract class CardRequest { @@ -16,6 +19,24 @@ public abstract class CardRequest { private String reference; + /** + * User's metadata. + *

+ * [Optional] + *

+ */ + private IssuingCardMetadata metadata; + + /** + * Date for the card to be automatically revoked. Must be after the current date. + *

+ * [Optional] + *

+ * Format: yyyy-MM-dd + */ + @SerializedName("revocation_date") + private LocalDate revocationDate; + @SerializedName("card_product_id") private String cardProductId; diff --git a/src/main/java/com/checkout/issuing/cards/requests/create/PhysicalCardRequest.java b/src/main/java/com/checkout/issuing/cards/requests/create/PhysicalCardRequest.java index 9a6c52e2..a45e6fd3 100644 --- a/src/main/java/com/checkout/issuing/cards/requests/create/PhysicalCardRequest.java +++ b/src/main/java/com/checkout/issuing/cards/requests/create/PhysicalCardRequest.java @@ -1,7 +1,10 @@ package com.checkout.issuing.cards.requests.create; import com.checkout.issuing.cards.CardType; +import com.checkout.issuing.cards.requests.update.IssuingCardMetadata; import com.google.gson.annotations.SerializedName; + +import java.time.LocalDate; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -24,9 +27,13 @@ private PhysicalCardRequest(final String cardholderId, final String cardProductId, final String displayName, final Boolean activateCard, - final ShippingInstruction shippingInstructions) { + final ShippingInstruction shippingInstructions, + final IssuingCardMetadata metadata, + final LocalDate revocationDate) { super(CardType.PHYSICAL, cardholderId, lifetime, reference, cardProductId, displayName, activateCard); this.shippingInstructions = shippingInstructions; + setMetadata(metadata); + setRevocationDate(revocationDate); } } \ No newline at end of file diff --git a/src/main/java/com/checkout/issuing/cards/requests/create/VirtualCardRequest.java b/src/main/java/com/checkout/issuing/cards/requests/create/VirtualCardRequest.java index 402fd508..7de66acd 100644 --- a/src/main/java/com/checkout/issuing/cards/requests/create/VirtualCardRequest.java +++ b/src/main/java/com/checkout/issuing/cards/requests/create/VirtualCardRequest.java @@ -1,7 +1,10 @@ package com.checkout.issuing.cards.requests.create; import com.checkout.issuing.cards.CardType; +import com.checkout.issuing.cards.requests.update.IssuingCardMetadata; import com.google.gson.annotations.SerializedName; + +import java.time.LocalDate; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -24,8 +27,12 @@ private VirtualCardRequest(final String cardholderId, final String cardProductId, final String displayName, final Boolean activateCard, - final Boolean isSingleUse) { + final Boolean isSingleUse, + final IssuingCardMetadata metadata, + final LocalDate revocationDate) { super(CardType.VIRTUAL, cardholderId, lifetime, reference, cardProductId, displayName, activateCard); this.isSingleUse = isSingleUse; + setMetadata(metadata); + setRevocationDate(revocationDate); } } \ No newline at end of file diff --git a/src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsIT.java b/src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsIT.java new file mode 100644 index 00000000..cf9e7853 --- /dev/null +++ b/src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsIT.java @@ -0,0 +1,128 @@ +package com.checkout.issuing.newfields; + +import com.checkout.issuing.BaseIssuingTestIT; +import com.checkout.issuing.cardholders.CardholderResponse; +import com.checkout.issuing.cards.requests.create.CardLifetime; +import com.checkout.issuing.cards.requests.create.LifetimeUnit; +import com.checkout.issuing.cards.requests.create.PhysicalCardRequest; +import com.checkout.issuing.cards.requests.create.VirtualCardRequest; +import com.checkout.issuing.cards.requests.update.IssuingCardMetadata; +import com.checkout.issuing.cards.responses.CardResponse; +import org.junit.jupiter.api.BeforeAll; + +import java.time.LocalDate; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Integration tests for new fields on card creation: + * {@code metadata} (udf1–udf5) and {@code revocation_date}. + * + *

Disabled by default — requires issuing OAuth credentials and + * the {@code CHECKOUT_DEFAULT_OAUTH_ISSUING_*} environment variables.

+ */ +@Disabled("Requires issuing OAuth credentials and an enabled issuing sandbox account") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class IssuingCardNewFieldsIT extends BaseIssuingTestIT { + + private CardholderResponse cardholder; + + @BeforeAll + void setUp() { + cardholder = createCardholder(); + } + + @Test + void shouldCreateVirtualCardWithMetadata() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId(cardholder.getId()) + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .lifetime(CardLifetime.builder().unit(LifetimeUnit.YEARS).value(1).build()) + .metadata(IssuingCardMetadata.builder() + .udf1("integration_test") + .udf2("new_fields_suite") + .build()) + .build(); + + final CardResponse response = blocking(() -> + issuingApi.issuingClient().createCard(request)); + + assertNotNull(response); + assertNotNull(response.getId()); + } + + @Test + void shouldCreateVirtualCardWithRevocationDate() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId(cardholder.getId()) + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .lifetime(CardLifetime.builder().unit(LifetimeUnit.YEARS).value(1).build()) + .revocationDate(LocalDate.of(2027, 12, 31)) + .build(); + + final CardResponse response = blocking(() -> + issuingApi.issuingClient().createCard(request)); + + assertNotNull(response); + assertNotNull(response.getId()); + } + + @Test + void shouldCreateVirtualCardWithBothNewFields() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId(cardholder.getId()) + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .lifetime(CardLifetime.builder().unit(LifetimeUnit.MONTHS).value(12).build()) + .metadata(IssuingCardMetadata.builder() + .udf1("campaign_q4") + .udf3("region_emea") + .udf5("priority_high") + .build()) + .revocationDate(LocalDate.of(2027, 6, 30)) + .build(); + + final CardResponse response = blocking(() -> + issuingApi.issuingClient().createCard(request)); + + assertNotNull(response); + assertNotNull(response.getId()); + } + + @Test + void shouldCreatePhysicalCardWithMetadata() { + final PhysicalCardRequest request = PhysicalCardRequest.builder() + .cardholderId(cardholder.getId()) + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .lifetime(CardLifetime.builder().unit(LifetimeUnit.YEARS).value(3).build()) + .metadata(IssuingCardMetadata.builder() + .udf1("physical_card_test") + .build()) + .build(); + + final CardResponse response = blocking(() -> + issuingApi.issuingClient().createCard(request)); + + assertNotNull(response); + assertNotNull(response.getId()); + } + + @Test + void shouldCreatePhysicalCardWithRevocationDate() { + final PhysicalCardRequest request = PhysicalCardRequest.builder() + .cardholderId(cardholder.getId()) + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .lifetime(CardLifetime.builder().unit(LifetimeUnit.YEARS).value(2).build()) + .revocationDate(LocalDate.of(2026, 9, 15)) + .build(); + + final CardResponse response = blocking(() -> + issuingApi.issuingClient().createCard(request)); + + assertNotNull(response); + assertNotNull(response.getId()); + } +} diff --git a/src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsSchemaTest.java b/src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsSchemaTest.java new file mode 100644 index 00000000..d8403ee9 --- /dev/null +++ b/src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsSchemaTest.java @@ -0,0 +1,176 @@ +package com.checkout.issuing.newfields; + +import com.checkout.GsonSerializer; +import com.checkout.issuing.cards.requests.create.CardLifetime; +import com.checkout.issuing.cards.requests.create.LifetimeUnit; +import com.checkout.issuing.cards.requests.create.PhysicalCardRequest; +import com.checkout.issuing.cards.requests.create.VirtualCardRequest; +import com.checkout.issuing.cards.requests.update.IssuingCardMetadata; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Schema / serialization tests for the new fields added to + * {@link com.checkout.issuing.cards.requests.create.CardRequest}: + *
    + *
  • {@code metadata} (udf1–udf5) — available on card creation, not just update
  • + *
  • {@code revocation_date} — automatic revocation date (yyyy-MM-dd)
  • + *
+ */ +class IssuingCardNewFieldsSchemaTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + // ─── VirtualCardRequest ───────────────────────────────────────────────── + + @Test + void shouldSerializeVirtualCardRequestWithMetadata() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId("crh_d3ozhf43pcq2xbldn2g45qnb44") + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .metadata(IssuingCardMetadata.builder() + .udf1("customer_tier_gold") + .udf2("campaign_2026_q1") + .build()) + .build(); + + final String json = serializer.toJson(request); + + assertNotNull(json); + assertTrue(json.contains("\"metadata\""), "Should contain metadata field"); + assertTrue(json.contains("\"udf1\""), "Should contain udf1"); + assertTrue(json.contains("\"customer_tier_gold\""), "Should contain udf1 value"); + assertTrue(json.contains("\"udf2\""), "Should contain udf2"); + assertTrue(json.contains("\"campaign_2026_q1\""), "Should contain udf2 value"); + } + + @Test + void shouldSerializeVirtualCardRequestWithRevocationDate() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId("crh_d3ozhf43pcq2xbldn2g45qnb44") + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .revocationDate(LocalDate.of(2027, 3, 12)) + .build(); + + final String json = serializer.toJson(request); + + assertNotNull(json); + assertTrue(json.contains("\"revocation_date\""), "Should contain revocation_date"); + assertTrue(json.contains("2027-03-12"), "Should contain revocation date value"); + } + + @Test + void shouldSerializeVirtualCardRequestWithBothNewFields() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId("crh_d3ozhf43pcq2xbldn2g45qnb44") + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .metadata(IssuingCardMetadata.builder() + .udf1("value1") + .udf3("value3") + .udf5("value5") + .build()) + .revocationDate(LocalDate.of(2028, 6, 30)) + .build(); + + assertDoesNotThrow(() -> { + final String json = serializer.toJson(request); + assertNotNull(json); + assertTrue(json.contains("\"metadata\"")); + assertTrue(json.contains("\"revocation_date\"")); + assertTrue(json.contains("\"udf1\"")); + assertTrue(json.contains("\"udf5\"")); + assertTrue(json.contains("2028-06-30")); + }); + } + + @Test + void shouldNotIncludeNullMetadataInSerialization() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId("crh_d3ozhf43pcq2xbldn2g45qnb44") + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .build(); + + final String json = serializer.toJson(request); + + assertFalse(json.contains("\"metadata\""), + "Should not include metadata when null"); + assertFalse(json.contains("\"revocation_date\""), + "Should not include revocation_date when null"); + } + + @Test + void shouldSerializeAllFiveUdfFields() { + final IssuingCardMetadata metadata = IssuingCardMetadata.builder() + .udf1("v1").udf2("v2").udf3("v3").udf4("v4").udf5("v5") + .build(); + + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId("crh_d3ozhf43pcq2xbldn2g45qnb44") + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .metadata(metadata) + .build(); + + final String json = serializer.toJson(request); + + for (int i = 1; i <= 5; i++) { + assertTrue(json.contains("\"udf" + i + "\""), "Should contain udf" + i); + assertTrue(json.contains("\"v" + i + "\""), "Should contain value for udf" + i); + } + } + + // ─── PhysicalCardRequest ───────────────────────────────────────────────── + + @Test + void shouldSerializePhysicalCardRequestWithMetadata() { + final PhysicalCardRequest request = PhysicalCardRequest.builder() + .cardholderId("crh_d3ozhf43pcq2xbldn2g45qnb44") + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .metadata(IssuingCardMetadata.builder() + .udf1("physical_card_ref") + .build()) + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"metadata\"")); + assertTrue(json.contains("\"physical_card_ref\"")); + } + + @Test + void shouldSerializePhysicalCardRequestWithRevocationDate() { + final PhysicalCardRequest request = PhysicalCardRequest.builder() + .cardholderId("crh_d3ozhf43pcq2xbldn2g45qnb44") + .cardProductId("pro_7syjig3jq3mezlc3vjrdpfitl4") + .lifetime(CardLifetime.builder().unit(LifetimeUnit.YEARS).value(3).build()) + .revocationDate(LocalDate.of(2027, 12, 31)) + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"revocation_date\"")); + assertTrue(json.contains("2027-12-31")); + } + + @Test + void shouldDeserializeSwaggerExampleVirtualCardRequest() { + final String json = "{" + + "\"type\":\"virtual\"," + + "\"cardholder_id\":\"crh_d3ozhf43pcq2xbldn2g45qnb44\"," + + "\"card_product_id\":\"pro_7syjig3jq3mezlc3vjrdpfitl4\"," + + "\"lifetime\":{\"unit\":\"Months\",\"value\":6}," + + "\"reference\":\"X-123456-N11\"," + + "\"metadata\":{\"udf1\":\"metadata1\",\"udf2\":\"metadata2\"}," + + "\"revocation_date\":\"2027-03-12\"" + + "}"; + + final VirtualCardRequest deserialized = serializer.fromJson(json, VirtualCardRequest.class); + + assertNotNull(deserialized); + assertNotNull(deserialized.getMetadata()); + assertEquals("metadata1", deserialized.getMetadata().getUdf1()); + assertEquals("metadata2", deserialized.getMetadata().getUdf2()); + } +} diff --git a/src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsUnitTest.java b/src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsUnitTest.java new file mode 100644 index 00000000..1f5ced03 --- /dev/null +++ b/src/test/java/com/checkout/issuing/newfields/IssuingCardNewFieldsUnitTest.java @@ -0,0 +1,159 @@ +package com.checkout.issuing.newfields; + +import com.checkout.issuing.cards.requests.create.CardLifetime; +import com.checkout.issuing.cards.requests.create.LifetimeUnit; +import com.checkout.issuing.cards.requests.create.PhysicalCardRequest; +import com.checkout.issuing.cards.requests.create.VirtualCardRequest; +import com.checkout.issuing.cards.requests.update.IssuingCardMetadata; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests verifying that new fields ({@code metadata}, {@code revocation_date}) + * are correctly wired into both {@link VirtualCardRequest} and + * {@link PhysicalCardRequest} builders. + */ +class IssuingCardNewFieldsUnitTest { + + private static final String CARDHOLDER_ID = "crh_d3ozhf43pcq2xbldn2g45qnb44"; + private static final String CARD_PRODUCT_ID = "pro_7syjig3jq3mezlc3vjrdpfitl4"; + + // ─── VirtualCardRequest ───────────────────────────────────────────────── + + @Test + void shouldSetMetadataOnVirtualCardRequestViaBuilder() { + final IssuingCardMetadata metadata = IssuingCardMetadata.builder() + .udf1("tier_gold") + .udf2("region_eu") + .build(); + + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId(CARDHOLDER_ID) + .cardProductId(CARD_PRODUCT_ID) + .metadata(metadata) + .build(); + + assertNotNull(request.getMetadata()); + assertEquals("tier_gold", request.getMetadata().getUdf1()); + assertEquals("region_eu", request.getMetadata().getUdf2()); + assertNull(request.getMetadata().getUdf3()); + } + + @Test + void shouldSetRevocationDateOnVirtualCardRequestViaBuilder() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId(CARDHOLDER_ID) + .cardProductId(CARD_PRODUCT_ID) + .revocationDate(LocalDate.of(2027, 6, 15)) + .build(); + + assertEquals(LocalDate.of(2027, 6, 15), request.getRevocationDate()); + } + + @Test + void shouldSetBothNewFieldsOnVirtualCardRequest() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId(CARDHOLDER_ID) + .cardProductId(CARD_PRODUCT_ID) + .metadata(IssuingCardMetadata.builder().udf1("v1").udf5("v5").build()) + .revocationDate(LocalDate.of(2028, 1, 1)) + .isSingleUse(true) + .build(); + + assertNotNull(request.getMetadata()); + assertEquals("v1", request.getMetadata().getUdf1()); + assertEquals("v5", request.getMetadata().getUdf5()); + assertEquals(LocalDate.of(2028, 1, 1), request.getRevocationDate()); + assertTrue(request.getIsSingleUse()); + } + + @Test + void shouldHaveNullMetadataAndRevocationDateByDefault() { + final VirtualCardRequest request = VirtualCardRequest.builder() + .cardholderId(CARDHOLDER_ID) + .cardProductId(CARD_PRODUCT_ID) + .build(); + + assertNull(request.getMetadata()); + assertNull(request.getRevocationDate()); + } + + // ─── PhysicalCardRequest ───────────────────────────────────────────────── + + @Test + void shouldSetMetadataOnPhysicalCardRequestViaBuilder() { + final IssuingCardMetadata metadata = IssuingCardMetadata.builder() + .udf1("physical_udf1") + .udf3("physical_udf3") + .build(); + + final PhysicalCardRequest request = PhysicalCardRequest.builder() + .cardholderId(CARDHOLDER_ID) + .cardProductId(CARD_PRODUCT_ID) + .metadata(metadata) + .build(); + + assertNotNull(request.getMetadata()); + assertEquals("physical_udf1", request.getMetadata().getUdf1()); + assertNull(request.getMetadata().getUdf2()); + assertEquals("physical_udf3", request.getMetadata().getUdf3()); + } + + @Test + void shouldSetRevocationDateOnPhysicalCardRequestViaBuilder() { + final PhysicalCardRequest request = PhysicalCardRequest.builder() + .cardholderId(CARDHOLDER_ID) + .cardProductId(CARD_PRODUCT_ID) + .lifetime(CardLifetime.builder().unit(LifetimeUnit.MONTHS).value(18).build()) + .revocationDate(LocalDate.of(2026, 9, 30)) + .build(); + + assertEquals(LocalDate.of(2026, 9, 30), request.getRevocationDate()); + assertNotNull(request.getLifetime()); + assertEquals(LifetimeUnit.MONTHS, request.getLifetime().getUnit()); + assertEquals(18, request.getLifetime().getValue()); + } + + @Test + void shouldSetBothNewFieldsOnPhysicalCardRequest() { + final PhysicalCardRequest request = PhysicalCardRequest.builder() + .cardholderId(CARDHOLDER_ID) + .cardProductId(CARD_PRODUCT_ID) + .metadata(IssuingCardMetadata.builder().udf2("shipping_priority").build()) + .revocationDate(LocalDate.of(2027, 3, 12)) + .build(); + + assertNotNull(request.getMetadata()); + assertEquals("shipping_priority", request.getMetadata().getUdf2()); + assertEquals(LocalDate.of(2027, 3, 12), request.getRevocationDate()); + } + + @Test + void shouldHaveNullNewFieldsByDefaultOnPhysicalCardRequest() { + final PhysicalCardRequest request = PhysicalCardRequest.builder() + .cardholderId(CARDHOLDER_ID) + .cardProductId(CARD_PRODUCT_ID) + .build(); + + assertNull(request.getMetadata()); + assertNull(request.getRevocationDate()); + } + + // ─── IssuingCardMetadata ───────────────────────────────────────────────── + + @Test + void shouldBuildIssuingCardMetadataWithAllFields() { + final IssuingCardMetadata metadata = IssuingCardMetadata.builder() + .udf1("a").udf2("b").udf3("c").udf4("d").udf5("e") + .build(); + + assertEquals("a", metadata.getUdf1()); + assertEquals("b", metadata.getUdf2()); + assertEquals("c", metadata.getUdf3()); + assertEquals("d", metadata.getUdf4()); + assertEquals("e", metadata.getUdf5()); + } +} From 3f959412d4b9bca0254a96050b4a4146a65f227f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:49:48 +0200 Subject: [PATCH 4/8] feat: add routing, subscription, and authentication fields to payments Add PaymentRouting, PaymentRoutingScheme, PaymentSubscription models to PaymentRequest; add PaymentResponseAuthentication and AuthenticationExperience to GetPaymentResponse. Includes unit, schema, and integration tests. --- .../com/checkout/payments/PaymentRouting.java | 44 +++ .../payments/PaymentRoutingScheme.java | 55 ++++ .../payments/PaymentSubscription.java | 28 ++ .../payments/request/PaymentRequest.java | 30 +- .../response/AuthenticationExperience.java | 13 + .../payments/response/GetPaymentResponse.java | 8 + .../PaymentResponseAuthentication.java | 22 ++ .../newfields/PaymentNewFieldsIT.java | 129 +++++++++ .../newfields/PaymentNewFieldsSchemaTest.java | 262 ++++++++++++++++++ .../newfields/PaymentNewFieldsUnitTest.java | 166 +++++++++++ 10 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/checkout/payments/PaymentRouting.java create mode 100644 src/main/java/com/checkout/payments/PaymentRoutingScheme.java create mode 100644 src/main/java/com/checkout/payments/PaymentSubscription.java create mode 100644 src/main/java/com/checkout/payments/response/AuthenticationExperience.java create mode 100644 src/main/java/com/checkout/payments/response/PaymentResponseAuthentication.java create mode 100644 src/test/java/com/checkout/payments/newfields/PaymentNewFieldsIT.java create mode 100644 src/test/java/com/checkout/payments/newfields/PaymentNewFieldsSchemaTest.java create mode 100644 src/test/java/com/checkout/payments/newfields/PaymentNewFieldsUnitTest.java diff --git a/src/main/java/com/checkout/payments/PaymentRouting.java b/src/main/java/com/checkout/payments/PaymentRouting.java new file mode 100644 index 00000000..5f4259ff --- /dev/null +++ b/src/main/java/com/checkout/payments/PaymentRouting.java @@ -0,0 +1,44 @@ +package com.checkout.payments; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Controls processor attempts at the payment level. + *

+ * [Optional] + *

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaymentRouting { + + /** + * Specifies the processing rules for the payment. + *

+ * [Optional] + *

+ */ + private List attempts; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RoutingAttempt { + + /** + * The card scheme to use for the payment attempt. + *

+ * [Optional] + *

+ */ + private PaymentRoutingScheme scheme; + } +} diff --git a/src/main/java/com/checkout/payments/PaymentRoutingScheme.java b/src/main/java/com/checkout/payments/PaymentRoutingScheme.java new file mode 100644 index 00000000..09b23f30 --- /dev/null +++ b/src/main/java/com/checkout/payments/PaymentRoutingScheme.java @@ -0,0 +1,55 @@ +package com.checkout.payments; + +import com.google.gson.annotations.SerializedName; + +public enum PaymentRoutingScheme { + + @SerializedName("accel") + ACCEL, + + @SerializedName("amex") + AMEX, + + @SerializedName("cartes_bancaires") + CARTES_BANCAIRES, + + @SerializedName("diners") + DINERS, + + @SerializedName("discover") + DISCOVER, + + @SerializedName("jcb") + JCB, + + @SerializedName("mada") + MADA, + + @SerializedName("maestro") + MAESTRO, + + @SerializedName("mastercard") + MASTERCARD, + + @SerializedName("nyce") + NYCE, + + @SerializedName("omannet") + OMANNET, + + @SerializedName("pulse") + PULSE, + + @SerializedName("shazam") + SHAZAM, + + @SerializedName("star") + STAR, + + @SerializedName("upi") + UPI, + + @SerializedName("visa") + VISA + +} diff --git a/src/main/java/com/checkout/payments/PaymentSubscription.java b/src/main/java/com/checkout/payments/PaymentSubscription.java new file mode 100644 index 00000000..54b61f0f --- /dev/null +++ b/src/main/java/com/checkout/payments/PaymentSubscription.java @@ -0,0 +1,28 @@ +package com.checkout.payments; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The details of the subscription. + *

+ * [Optional] + *

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaymentSubscription { + + /** + * The ID or reference linking a series of recurring payments together. + *

+ * [Optional] + *

+ * Length: <= 50 characters + */ + private String id; +} diff --git a/src/main/java/com/checkout/payments/request/PaymentRequest.java b/src/main/java/com/checkout/payments/request/PaymentRequest.java index 1096ace0..0abe92b8 100644 --- a/src/main/java/com/checkout/payments/request/PaymentRequest.java +++ b/src/main/java/com/checkout/payments/request/PaymentRequest.java @@ -15,6 +15,8 @@ import com.checkout.payments.ThreeDSRequest; import com.checkout.payments.request.source.AbstractRequestSource; import com.checkout.payments.PaymentPlan; +import com.checkout.payments.PaymentRouting; +import com.checkout.payments.PaymentSubscription; import com.checkout.payments.sender.PaymentSender; import com.google.gson.annotations.SerializedName; import lombok.Builder; @@ -65,6 +67,16 @@ public final class PaymentRequest { @SerializedName("capture_on") private Instant captureOn; + /** + * The date and time when the Multibanco payment expires in UTC. + *

+ * [Optional] + *

+ * Format: date-time (ISO 8601) + */ + @SerializedName("expire_on") + private Instant expireOn; + private CustomerRequest customer; @SerializedName("billing_descriptor") @@ -112,11 +124,27 @@ public final class PaymentRequest { private PaymentRetryRequest retry; + /** + * The details of the subscription. + *

+ * [Optional] + *

+ */ + private PaymentSubscription subscription; + @Builder.Default private Map metadata = new HashMap<>(); private PaymentSegment segment; - private PaymentInstruction instruction ; + private PaymentInstruction instruction; + + /** + * Controls processor attempts at the payment level. + *

+ * [Optional] + *

+ */ + private PaymentRouting routing; } diff --git a/src/main/java/com/checkout/payments/response/AuthenticationExperience.java b/src/main/java/com/checkout/payments/response/AuthenticationExperience.java new file mode 100644 index 00000000..38565c2e --- /dev/null +++ b/src/main/java/com/checkout/payments/response/AuthenticationExperience.java @@ -0,0 +1,13 @@ +package com.checkout.payments.response; + +import com.google.gson.annotations.SerializedName; + +public enum AuthenticationExperience { + + @SerializedName("google_spa") + GOOGLE_SPA, + + @SerializedName("3ds") + THREE_DS + +} diff --git a/src/main/java/com/checkout/payments/response/GetPaymentResponse.java b/src/main/java/com/checkout/payments/response/GetPaymentResponse.java index 4c0fc57a..a447db04 100644 --- a/src/main/java/com/checkout/payments/response/GetPaymentResponse.java +++ b/src/main/java/com/checkout/payments/response/GetPaymentResponse.java @@ -80,6 +80,14 @@ public final class GetPaymentResponse extends Resource { @SerializedName("3ds") private ThreeDSData threeDSData; + /** + * Provides information relating to the authentication of the payment. + *

+ * [Optional] + *

+ */ + private PaymentResponseAuthentication authentication; + private RiskAssessment risk; private CustomerResponse customer; diff --git a/src/main/java/com/checkout/payments/response/PaymentResponseAuthentication.java b/src/main/java/com/checkout/payments/response/PaymentResponseAuthentication.java new file mode 100644 index 00000000..12e77189 --- /dev/null +++ b/src/main/java/com/checkout/payments/response/PaymentResponseAuthentication.java @@ -0,0 +1,22 @@ +package com.checkout.payments.response; + +import lombok.Data; + +/** + * Provides information relating to the authentication of the payment. + *

+ * [Optional] + *

+ */ +@Data +public class PaymentResponseAuthentication { + + /** + * The authentication experience that was used to authenticate the payment. + *

+ * [Optional] + *

+ */ + private AuthenticationExperience experience; + +} diff --git a/src/test/java/com/checkout/payments/newfields/PaymentNewFieldsIT.java b/src/test/java/com/checkout/payments/newfields/PaymentNewFieldsIT.java new file mode 100644 index 00000000..b27862f5 --- /dev/null +++ b/src/test/java/com/checkout/payments/newfields/PaymentNewFieldsIT.java @@ -0,0 +1,129 @@ +package com.checkout.payments.newfields; + +import com.checkout.CardSourceHelper; +import com.checkout.common.Currency; +import com.checkout.payments.PaymentRouting; +import com.checkout.payments.PaymentRoutingScheme; +import com.checkout.payments.PaymentSubscription; +import com.checkout.payments.AbstractPaymentsTestIT; +import com.checkout.payments.PaymentsClient; +import com.checkout.payments.request.PaymentRequest; +import com.checkout.payments.request.source.RequestCardSource; +import com.checkout.payments.response.GetPaymentResponse; +import com.checkout.payments.response.PaymentResponse; +import com.checkout.payments.sender.PaymentIndividualSender; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Collections; +import java.util.UUID; + +import static com.checkout.CardSourceHelper.getCorporateSender; +import static com.checkout.CardSourceHelper.getIndividualSender; +import static com.checkout.CardSourceHelper.getRequestCardSource; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Integration tests for new {@link PaymentRequest} fields introduced in the swagger update: + * {@code expire_on}, {@code routing}, {@code subscription}. + * + *

Also verifies that {@code GetPaymentResponse.authentication} is populated + * when returned by the API.

+ */ +class PaymentNewFieldsIT extends AbstractPaymentsTestIT { + + @Test + void shouldRequestPaymentWithSubscription() { + final RequestCardSource source = getRequestCardSource(); + final PaymentIndividualSender sender = getIndividualSender(); + + final PaymentRequest request = PaymentRequest.builder() + .source(source) + .sender(sender) + .amount(100L) + .currency(Currency.EUR) + .capture(false) + .reference(UUID.randomUUID().toString()) + .subscription(PaymentSubscription.builder() + .id("sub_" + UUID.randomUUID().toString().replace("-", "").substring(0, 20)) + .build()) + .build(); + + final PaymentResponse response = blocking(() -> paymentsClient.requestPayment(request)); + + assertNotNull(response); + assertNotNull(response.getId()); + } + + @Test + void shouldRequestPaymentWithRouting() { + final RequestCardSource source = getRequestCardSource(); + final PaymentIndividualSender sender = getIndividualSender(); + + final PaymentRequest request = PaymentRequest.builder() + .source(source) + .sender(sender) + .amount(100L) + .currency(Currency.EUR) + .capture(false) + .reference(UUID.randomUUID().toString()) + .routing(PaymentRouting.builder() + .attempts(Arrays.asList( + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.VISA).build(), + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.MASTERCARD).build() + )) + .build()) + .build(); + + final PaymentResponse response = blocking(() -> paymentsClient.requestPayment(request)); + + assertNotNull(response); + assertNotNull(response.getId()); + } + + @Disabled("expire_on is Multibanco-specific; requires an APM-enabled processing channel") + @Test + void shouldRequestMultibancoPaymentWithExpireOn() { + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.EUR) + .amount(1000L) + .reference(UUID.randomUUID().toString()) + .expireOn(Instant.now().plus(2, ChronoUnit.DAYS)) + .build(); + + final PaymentResponse response = blocking(() -> paymentsClient.requestPayment(request)); + + assertNotNull(response); + assertNotNull(response.getId()); + } + + @Test + void shouldGetPaymentAndReadAuthenticationField() { + // First make a payment + final RequestCardSource source = getRequestCardSource(); + final PaymentIndividualSender sender = getIndividualSender(); + + final PaymentRequest request = PaymentRequest.builder() + .source(source) + .sender(sender) + .amount(100L) + .currency(Currency.EUR) + .capture(false) + .reference(UUID.randomUUID().toString()) + .build(); + + final PaymentResponse paymentResponse = blocking(() -> paymentsClient.requestPayment(request)); + assertNotNull(paymentResponse); + + // Retrieve it and check the authentication field (may be null for non-3DS flows) + final GetPaymentResponse getResponse = blocking(() -> + paymentsClient.getPayment(paymentResponse.getId())); + + assertNotNull(getResponse); + assertNotNull(getResponse.getId()); + // authentication field present only for 3DS/Google-SPA flows — no hard assert on value + } +} diff --git a/src/test/java/com/checkout/payments/newfields/PaymentNewFieldsSchemaTest.java b/src/test/java/com/checkout/payments/newfields/PaymentNewFieldsSchemaTest.java new file mode 100644 index 00000000..461caa18 --- /dev/null +++ b/src/test/java/com/checkout/payments/newfields/PaymentNewFieldsSchemaTest.java @@ -0,0 +1,262 @@ +package com.checkout.payments.newfields; + +import com.checkout.GsonSerializer; +import com.checkout.common.Currency; +import com.checkout.payments.PaymentRouting; +import com.checkout.payments.PaymentRoutingScheme; +import com.checkout.payments.PaymentSubscription; +import com.checkout.payments.request.PaymentRequest; +import com.checkout.payments.response.GetPaymentResponse; +import com.checkout.payments.response.AuthenticationExperience; +import com.checkout.payments.response.PaymentResponseAuthentication; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Schema / serialization tests for new fields added to {@link PaymentRequest} + * and {@link GetPaymentResponse} in the latest swagger update: + *
    + *
  • {@code expire_on} ({@link Instant})
  • + *
  • {@code routing} ({@link PaymentRouting})
  • + *
  • {@code subscription} ({@link PaymentSubscription})
  • + *
  • {@code authentication} ({@link PaymentResponseAuthentication})
  • + *
+ */ +class PaymentNewFieldsSchemaTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + // ─── PaymentRequest ───────────────────────────────────────────────────── + + @Test + void shouldSerializeExpireOn() { + final Instant expiry = Instant.parse("2025-01-31T10:20:30.456Z"); + + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.EUR) + .expireOn(expiry) + .build(); + + final String json = serializer.toJson(request); + + assertNotNull(json); + assertTrue(json.contains("\"expire_on\""), + "Should serialize expireOn as expire_on"); + assertTrue(json.contains("2025-01-31"), + "Should contain the expiry date value"); + } + + @Test + void shouldSerializePaymentRouting() { + final PaymentRouting routing = PaymentRouting.builder() + .attempts(Arrays.asList( + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.VISA).build(), + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.MASTERCARD).build() + )) + .build(); + + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.USD) + .routing(routing) + .build(); + + final String json = serializer.toJson(request); + + assertNotNull(json); + assertTrue(json.contains("\"routing\""), "Should contain routing field"); + assertTrue(json.contains("\"attempts\""), "Should contain attempts array"); + assertTrue(json.contains("\"visa\""), "Should contain visa scheme"); + assertTrue(json.contains("\"mastercard\""), "Should contain mastercard scheme"); + } + + @Test + void shouldSerializePaymentRoutingWithSingleAttempt() { + final PaymentRouting routing = PaymentRouting.builder() + .attempts(Collections.singletonList( + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.AMEX).build() + )) + .build(); + + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.GBP) + .routing(routing) + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"amex\"")); + } + + @Test + void shouldSerializePaymentSubscription() { + final PaymentSubscription subscription = PaymentSubscription.builder() + .id("sub_ref_12345678901234567890") + .build(); + + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.USD) + .subscription(subscription) + .build(); + + final String json = serializer.toJson(request); + + assertNotNull(json); + assertTrue(json.contains("\"subscription\""), "Should contain subscription field"); + assertTrue(json.contains("\"sub_ref_12345678901234567890\""), "Should contain subscription id"); + } + + @Test + void shouldSerializeAllThreeNewFieldsTogether() { + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.USD) + .amount(1000L) + .expireOn(Instant.parse("2025-06-30T23:59:59Z")) + .routing(PaymentRouting.builder() + .attempts(Collections.singletonList( + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.VISA).build())) + .build()) + .subscription(PaymentSubscription.builder().id("sub_abc123").build()) + .build(); + + assertDoesNotThrow(() -> { + final String json = serializer.toJson(request); + assertNotNull(json); + assertTrue(json.contains("\"expire_on\"")); + assertTrue(json.contains("\"routing\"")); + assertTrue(json.contains("\"subscription\"")); + }); + } + + @Test + void shouldNotSerializeNullNewFields() { + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.USD) + .amount(100L) + .build(); + + final String json = serializer.toJson(request); + + // Gson skips null fields by default + assertFalse(json.contains("\"expire_on\""), + "Should not include expire_on when null"); + assertFalse(json.contains("\"routing\""), + "Should not include routing when null"); + assertFalse(json.contains("\"subscription\""), + "Should not include subscription when null"); + } + + // ─── GetPaymentResponse ────────────────────────────────────────────────── + + @Test + void shouldDeserializePaymentResponseAuthenticationWith3ds() { + final String json = "{" + + "\"id\":\"pay_abc123\"," + + "\"status\":\"Authorized\"," + + "\"authentication\":{\"experience\":\"3ds\"}" + + "}"; + + final GetPaymentResponse response = serializer.fromJson(json, GetPaymentResponse.class); + + assertNotNull(response); + assertNotNull(response.getAuthentication(), "authentication field should not be null"); + assertEquals(AuthenticationExperience.THREE_DS, response.getAuthentication().getExperience()); + } + + @Test + void shouldDeserializePaymentResponseAuthenticationWithGoogleSpa() { + final String json = "{" + + "\"id\":\"pay_xyz789\"," + + "\"status\":\"Authorized\"," + + "\"authentication\":{\"experience\":\"google_spa\"}" + + "}"; + + final GetPaymentResponse response = serializer.fromJson(json, GetPaymentResponse.class); + + assertNotNull(response); + assertNotNull(response.getAuthentication()); + assertEquals(AuthenticationExperience.GOOGLE_SPA, response.getAuthentication().getExperience()); + } + + @Test + void shouldDeserializeGetPaymentResponseWithoutAuthenticationField() { + final String json = "{" + + "\"id\":\"pay_no_auth\"," + + "\"status\":\"Authorized\"," + + "\"amount\":1000," + + "\"currency\":\"USD\"" + + "}"; + + final GetPaymentResponse response = serializer.fromJson(json, GetPaymentResponse.class); + + assertNotNull(response); + assertNull(response.getAuthentication(), + "authentication should be null when absent from response"); + } + + @Test + void shouldDeserializeGetPaymentResponseWithAllNewFields() { + final String json = "{" + + "\"id\":\"pay_full\"," + + "\"status\":\"Authorized\"," + + "\"amount\":5000," + + "\"currency\":\"EUR\"," + + "\"authentication\":{\"experience\":\"3ds\"}," + + "\"scheme_id\":\"scheme_abc\"," + + "\"cko_network_token_available\":true" + + "}"; + + final GetPaymentResponse response = serializer.fromJson(json, GetPaymentResponse.class); + + assertNotNull(response); + assertEquals("pay_full", response.getId()); + assertNotNull(response.getAuthentication()); + assertEquals(AuthenticationExperience.THREE_DS, response.getAuthentication().getExperience()); + } + + @Test + void shouldRoundTripPaymentRouting() { + final PaymentRouting original = PaymentRouting.builder() + .attempts(Arrays.asList( + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.VISA).build(), + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.MASTERCARD).build() + )) + .build(); + + final String json = serializer.toJson(original); + final PaymentRouting deserialized = serializer.fromJson(json, PaymentRouting.class); + + assertNotNull(deserialized); + assertEquals(2, deserialized.getAttempts().size()); + assertEquals(PaymentRoutingScheme.VISA, deserialized.getAttempts().get(0).getScheme()); + assertEquals(PaymentRoutingScheme.MASTERCARD, deserialized.getAttempts().get(1).getScheme()); + } + + @Test + void shouldRoundTripPaymentSubscription() { + final PaymentSubscription original = PaymentSubscription.builder() + .id("sub_round_trip_test") + .build(); + + final String json = serializer.toJson(original); + final PaymentSubscription deserialized = serializer.fromJson(json, PaymentSubscription.class); + + assertNotNull(deserialized); + assertEquals("sub_round_trip_test", deserialized.getId()); + } + + @Test + void shouldRoundTripPaymentResponseAuthentication() { + final String json = "{\"experience\":\"google_spa\"}"; + final PaymentResponseAuthentication auth = serializer.fromJson(json, PaymentResponseAuthentication.class); + + final String reJson = serializer.toJson(auth); + final PaymentResponseAuthentication reAuth = serializer.fromJson(reJson, PaymentResponseAuthentication.class); + + assertEquals(AuthenticationExperience.GOOGLE_SPA, reAuth.getExperience()); + } +} diff --git a/src/test/java/com/checkout/payments/newfields/PaymentNewFieldsUnitTest.java b/src/test/java/com/checkout/payments/newfields/PaymentNewFieldsUnitTest.java new file mode 100644 index 00000000..89584dee --- /dev/null +++ b/src/test/java/com/checkout/payments/newfields/PaymentNewFieldsUnitTest.java @@ -0,0 +1,166 @@ +package com.checkout.payments.newfields; + +import com.checkout.common.Currency; +import com.checkout.payments.PaymentRouting; +import com.checkout.payments.PaymentRoutingScheme; +import com.checkout.payments.PaymentSubscription; +import com.checkout.payments.request.PaymentRequest; +import com.checkout.payments.response.GetPaymentResponse; +import com.checkout.payments.response.AuthenticationExperience; +import com.checkout.payments.response.PaymentResponseAuthentication; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests verifying that new fields on {@link PaymentRequest} and + * {@link GetPaymentResponse} are correctly modelled (builder, getters, equals). + */ +class PaymentNewFieldsUnitTest { + + // ─── PaymentRouting ────────────────────────────────────────────────────── + + @Test + void shouldBuildPaymentRoutingWithMultipleAttempts() { + final PaymentRouting routing = PaymentRouting.builder() + .attempts(Arrays.asList( + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.VISA).build(), + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.MASTERCARD).build(), + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.AMEX).build() + )) + .build(); + + assertNotNull(routing.getAttempts()); + assertEquals(3, routing.getAttempts().size()); + assertEquals(PaymentRoutingScheme.VISA, routing.getAttempts().get(0).getScheme()); + assertEquals(PaymentRoutingScheme.MASTERCARD, routing.getAttempts().get(1).getScheme()); + assertEquals(PaymentRoutingScheme.AMEX, routing.getAttempts().get(2).getScheme()); + } + + @Test + void shouldBuildPaymentRoutingAttempt() { + final PaymentRouting.RoutingAttempt attempt = + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.JCB).build(); + + assertEquals(PaymentRoutingScheme.JCB, attempt.getScheme()); + } + + @Test + void shouldAllowEmptyRoutingAttemptsList() { + final PaymentRouting routing = PaymentRouting.builder() + .attempts(Collections.emptyList()) + .build(); + + assertNotNull(routing); + assertTrue(routing.getAttempts().isEmpty()); + } + + // ─── PaymentSubscription ───────────────────────────────────────────────── + + @Test + void shouldBuildPaymentSubscription() { + final PaymentSubscription subscription = PaymentSubscription.builder() + .id("sub_monthly_plan_abc") + .build(); + + assertNotNull(subscription); + assertEquals("sub_monthly_plan_abc", subscription.getId()); + } + + @Test + void shouldAllowNullSubscriptionId() { + final PaymentSubscription subscription = PaymentSubscription.builder().build(); + + assertNull(subscription.getId()); + } + + // ─── PaymentRequest new fields ─────────────────────────────────────────── + + @Test + void shouldSetExpireOnInPaymentRequest() { + final Instant expiry = Instant.parse("2025-12-31T23:59:59Z"); + + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.USD) + .expireOn(expiry) + .build(); + + assertEquals(expiry, request.getExpireOn()); + } + + @Test + void shouldSetRoutingInPaymentRequest() { + final PaymentRouting routing = PaymentRouting.builder() + .attempts(Collections.singletonList( + PaymentRouting.RoutingAttempt.builder().scheme(PaymentRoutingScheme.VISA).build())) + .build(); + + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.USD) + .routing(routing) + .build(); + + assertNotNull(request.getRouting()); + assertEquals(1, request.getRouting().getAttempts().size()); + assertEquals(PaymentRoutingScheme.VISA, request.getRouting().getAttempts().get(0).getScheme()); + } + + @Test + void shouldSetSubscriptionInPaymentRequest() { + final PaymentSubscription subscription = PaymentSubscription.builder() + .id("sub_ref_001") + .build(); + + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.EUR) + .subscription(subscription) + .build(); + + assertNotNull(request.getSubscription()); + assertEquals("sub_ref_001", request.getSubscription().getId()); + } + + @Test + void shouldHaveNullNewFieldsByDefault() { + final PaymentRequest request = PaymentRequest.builder() + .currency(Currency.GBP) + .amount(500L) + .build(); + + assertNull(request.getExpireOn()); + assertNull(request.getRouting()); + assertNull(request.getSubscription()); + } + + // ─── GetPaymentResponse.authentication ─────────────────────────────────── + + @Test + void shouldSetAuthenticationOnGetPaymentResponse() { + final PaymentResponseAuthentication auth = new PaymentResponseAuthentication(); + auth.setExperience(AuthenticationExperience.GOOGLE_SPA); + + final GetPaymentResponse response = new GetPaymentResponse(); + response.setAuthentication(auth); + + assertNotNull(response.getAuthentication()); + assertEquals(AuthenticationExperience.GOOGLE_SPA, response.getAuthentication().getExperience()); + } + + @Test + void shouldBuildPaymentResponseAuthentication() { + final PaymentResponseAuthentication auth = new PaymentResponseAuthentication(); + auth.setExperience(AuthenticationExperience.THREE_DS); + + assertEquals(AuthenticationExperience.THREE_DS, auth.getExperience()); + } + + @Test + void shouldHaveNullAuthenticationOnGetPaymentResponseByDefault() { + final GetPaymentResponse response = new GetPaymentResponse(); + assertNull(response.getAuthentication()); + } +} From f90e3665a2514f0d53cbd2e2658b5e76a2e40192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:49:58 +0200 Subject: [PATCH 5/8] feat: add Agentic Commerce delegate payment endpoint New AgenticCommerceClient with delegate payment support including request models (allowance, billing address, payment method, risk signals) and response model. Includes unit, serialization, and integration tests. --- .../AgenticCommerceClient.java | 13 + .../AgenticCommerceClientImpl.java | 43 +++ .../request/AllowanceReason.java | 9 + .../request/CardFundingType.java | 15 + .../request/CardNumberType.java | 18 ++ .../request/DelegatePaymentAllowance.java | 73 +++++ .../DelegatePaymentBillingAddress.java | 83 +++++ .../request/DelegatePaymentMethod.java | 163 ++++++++++ .../request/DelegatePaymentRequest.java | 56 ++++ .../agenticcommerce/request/RiskSignal.java | 37 +++ .../response/DelegatePaymentResponse.java | 41 +++ .../AgenticCommerceClientImplTest.java | 199 ++++++++++++ .../AgenticCommerceSerializationTest.java | 288 ++++++++++++++++++ .../AgenticCommerceTestIT.java | 147 +++++++++ 14 files changed, 1185 insertions(+) create mode 100644 src/main/java/com/checkout/agenticcommerce/AgenticCommerceClient.java create mode 100644 src/main/java/com/checkout/agenticcommerce/AgenticCommerceClientImpl.java create mode 100644 src/main/java/com/checkout/agenticcommerce/request/AllowanceReason.java create mode 100644 src/main/java/com/checkout/agenticcommerce/request/CardFundingType.java create mode 100644 src/main/java/com/checkout/agenticcommerce/request/CardNumberType.java create mode 100644 src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentAllowance.java create mode 100644 src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentBillingAddress.java create mode 100644 src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentMethod.java create mode 100644 src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentRequest.java create mode 100644 src/main/java/com/checkout/agenticcommerce/request/RiskSignal.java create mode 100644 src/main/java/com/checkout/agenticcommerce/response/DelegatePaymentResponse.java create mode 100644 src/test/java/com/checkout/agenticcommerce/AgenticCommerceClientImplTest.java create mode 100644 src/test/java/com/checkout/agenticcommerce/AgenticCommerceSerializationTest.java create mode 100644 src/test/java/com/checkout/agenticcommerce/AgenticCommerceTestIT.java diff --git a/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClient.java b/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClient.java new file mode 100644 index 00000000..8f90935f --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClient.java @@ -0,0 +1,13 @@ +package com.checkout.agenticcommerce; + +import com.checkout.agenticcommerce.request.DelegatePaymentRequest; +import com.checkout.agenticcommerce.response.DelegatePaymentResponse; + +import java.util.concurrent.CompletableFuture; + +public interface AgenticCommerceClient { + + CompletableFuture delegatePayment(DelegatePaymentRequest request); + + DelegatePaymentResponse delegatePaymentSync(DelegatePaymentRequest request); +} diff --git a/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClientImpl.java b/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClientImpl.java new file mode 100644 index 00000000..7957976e --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClientImpl.java @@ -0,0 +1,43 @@ +package com.checkout.agenticcommerce; + +import com.checkout.AbstractClient; +import com.checkout.ApiClient; +import com.checkout.CheckoutConfiguration; +import com.checkout.SdkAuthorizationType; +import com.checkout.agenticcommerce.request.DelegatePaymentRequest; +import com.checkout.agenticcommerce.response.DelegatePaymentResponse; +import com.checkout.common.CheckoutUtils; + +import java.util.concurrent.CompletableFuture; + +public class AgenticCommerceClientImpl extends AbstractClient implements AgenticCommerceClient { + + private static final String AGENTIC_COMMERCE_PATH = "agentic_commerce"; + private static final String DELEGATE_PAYMENT_PATH = "delegate_payment"; + + public AgenticCommerceClientImpl(final ApiClient apiClient, final CheckoutConfiguration configuration) { + super(apiClient, configuration, SdkAuthorizationType.SECRET_KEY_OR_OAUTH); + } + + @Override + public CompletableFuture delegatePayment(final DelegatePaymentRequest request) { + CheckoutUtils.validateParams("request", request); + return apiClient.postAsync( + buildPath(AGENTIC_COMMERCE_PATH, DELEGATE_PAYMENT_PATH), + sdkAuthorization(), + DelegatePaymentResponse.class, + request, + null); + } + + @Override + public DelegatePaymentResponse delegatePaymentSync(final DelegatePaymentRequest request) { + CheckoutUtils.validateParams("request", request); + return apiClient.post( + buildPath(AGENTIC_COMMERCE_PATH, DELEGATE_PAYMENT_PATH), + sdkAuthorization(), + DelegatePaymentResponse.class, + request, + null); + } +} diff --git a/src/main/java/com/checkout/agenticcommerce/request/AllowanceReason.java b/src/main/java/com/checkout/agenticcommerce/request/AllowanceReason.java new file mode 100644 index 00000000..d09feb12 --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/request/AllowanceReason.java @@ -0,0 +1,9 @@ +package com.checkout.agenticcommerce.request; + +import com.google.gson.annotations.SerializedName; + +public enum AllowanceReason { + + @SerializedName("one_time") + ONE_TIME +} diff --git a/src/main/java/com/checkout/agenticcommerce/request/CardFundingType.java b/src/main/java/com/checkout/agenticcommerce/request/CardFundingType.java new file mode 100644 index 00000000..288186ae --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/request/CardFundingType.java @@ -0,0 +1,15 @@ +package com.checkout.agenticcommerce.request; + +import com.google.gson.annotations.SerializedName; + +public enum CardFundingType { + + @SerializedName("credit") + CREDIT, + + @SerializedName("debit") + DEBIT, + + @SerializedName("prepaid") + PREPAID +} diff --git a/src/main/java/com/checkout/agenticcommerce/request/CardNumberType.java b/src/main/java/com/checkout/agenticcommerce/request/CardNumberType.java new file mode 100644 index 00000000..33b0f274 --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/request/CardNumberType.java @@ -0,0 +1,18 @@ +package com.checkout.agenticcommerce.request; + +import com.google.gson.annotations.SerializedName; + +public enum CardNumberType { + + /** + * A Funding Primary Account Number. The card number printed on the card. + */ + @SerializedName("fpan") + FPAN, + + /** + * A provisioned network token that represents the underlying card. + */ + @SerializedName("network_token") + NETWORK_TOKEN +} diff --git a/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentAllowance.java b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentAllowance.java new file mode 100644 index 00000000..7e631593 --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentAllowance.java @@ -0,0 +1,73 @@ +package com.checkout.agenticcommerce.request; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +/** + * The spending constraints that define what the delegated payment token is authorized for. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DelegatePaymentAllowance { + + /** + * Why this allowance is being granted (e.g. one-time use). + *

+ * [Required] + */ + private AllowanceReason reason; + + /** + * Maximum chargeable amount in the minor currency unit. + *

+ * [Required] + */ + @SerializedName("max_amount") + private Long maxAmount; + + /** + * ISO 4217 alphabetic currency code. + *

+ * [Required] + *

+ * Length: >= 3 characters + *

+ * Length: <= 3 characters + */ + private String currency; + + /** + * Merchant that will process the payment. + *

+ * [Required] + *

+ * Length: <= 256 characters + */ + @SerializedName("merchant_id") + private String merchantId; + + /** + * Checkout session associated with this delegated payment. + *

+ * [Required] + */ + @SerializedName("checkout_session_id") + private String checkoutSessionId; + + /** + * When the delegated payment authorization expires (must be in the future). + *

+ * [Required] + *

+ * Format: date-time (RFC 3339) + */ + @SerializedName("expires_at") + private Instant expiresAt; +} diff --git a/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentBillingAddress.java b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentBillingAddress.java new file mode 100644 index 00000000..20029d86 --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentBillingAddress.java @@ -0,0 +1,83 @@ +package com.checkout.agenticcommerce.request; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The customer billing address associated with the delegated payment. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DelegatePaymentBillingAddress { + + /** + * Customer or cardholder full name. + *

+ * [Required] + *

+ * Length: <= 256 characters + */ + private String name; + + /** + * First line of the street address. + *

+ * [Required] + *

+ * Length: <= 60 characters + */ + @SerializedName("line_one") + private String lineOne; + + /** + * Second line of the street address. + *

+ * [Optional] + *

+ * Length: <= 60 characters + */ + @SerializedName("line_two") + private String lineTwo; + + /** + * City or locality. + *

+ * [Required] + *

+ * Length: <= 60 characters + */ + private String city; + + /** + * State, county, province, or region. + *

+ * [Optional] + */ + private String state; + + /** + * Postal or ZIP code. + *

+ * [Required] + *

+ * Length: <= 20 characters + */ + @SerializedName("postal_code") + private String postalCode; + + /** + * ISO 3166-1 alpha-2 country code. + *

+ * [Required] + *

+ * Length: >= 2 characters + *

+ * Length: <= 2 characters + */ + private String country; +} diff --git a/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentMethod.java b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentMethod.java new file mode 100644 index 00000000..f0e2be64 --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentMethod.java @@ -0,0 +1,163 @@ +package com.checkout.agenticcommerce.request; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * The card details for a delegated payment. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DelegatePaymentMethod { + + /** + * Payment method type. + *

+ * [Required] + *

+ * Enum: "card" + */ + private String type; + + /** + * Whether the number is a full PAN or a network token. + *

+ * [Required] + */ + @SerializedName("card_number_type") + private CardNumberType cardNumberType; + + /** + * Card or token number. + *

+ * [Required] + */ + private String number; + + /** + * Expiry month (two digits). + *

+ * [Optional] + *

+ * Length: >= 2 characters + *

+ * Length: <= 2 characters + */ + @SerializedName("exp_month") + private String expMonth; + + /** + * Expiry year (four digits). + *

+ * [Optional] + *

+ * Length: >= 4 characters + *

+ * Length: <= 4 characters + */ + @SerializedName("exp_year") + private String expYear; + + /** + * Cardholder name as shown on the card. + *

+ * [Optional] + */ + private String name; + + /** + * Card verification value (CVC/CVV). + *

+ * [Optional] + *

+ * Length: >= 3 characters + *

+ * Length: <= 4 characters + */ + private String cvc; + + /** + * Cryptogram for network token transactions (required when {@code card_number_type} is network token). + *

+ * [Optional] + */ + private String cryptogram; + + /** + * Electronic Commerce Indicator (ECI) or Security Level Indicator (SLI) for network token flows. + *

+ * [Optional] + */ + @SerializedName("eci_value") + private String eciValue; + + /** + * Verification checks already performed on the card (array of strings). + *

+ * [Optional] + */ + @SerializedName("checks_performed") + private List checksPerformed; + + /** + * Issuer Identification Number (IIN/BIN), typically the first six digits of the PAN. + *

+ * [Optional] + *

+ * Length: >= 6 characters + *

+ * Length: <= 6 characters + */ + private String iin; + + /** + * Card funding type for display (credit, debit, or prepaid). + *

+ * [Optional] + */ + @SerializedName("display_card_funding_type") + private CardFundingType displayCardFundingType; + + /** + * Wallet type for display (e.g. Apple Pay, Google Pay). + *

+ * [Optional] + */ + @SerializedName("display_wallet_type") + private String displayWalletType; + + /** + * Card brand for display (e.g. Visa, Mastercard). + *

+ * [Optional] + */ + @SerializedName("display_brand") + private String displayBrand; + + /** + * Last four digits of the card for display. + *

+ * [Optional] + *

+ * Length: >= 4 characters + *

+ * Length: <= 4 characters + */ + @SerializedName("display_last4") + private String displayLast4; + + /** + * Additional payment method metadata (string values only). + *

+ * [Required] + */ + private Map metadata; +} diff --git a/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentRequest.java b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentRequest.java new file mode 100644 index 00000000..37167b81 --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentRequest.java @@ -0,0 +1,56 @@ +package com.checkout.agenticcommerce.request; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * The request body for creating a delegated payment token. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DelegatePaymentRequest { + + /** + * Card payment method details for the delegated payment. + *

[Required]

+ */ + @SerializedName("payment_method") + private DelegatePaymentMethod paymentMethod; + + /** + * Spending constraints authorizing use of the delegated payment token. + *

[Required]

+ */ + private DelegatePaymentAllowance allowance; + + /** + * Customer billing address for the delegated payment. + *

[Optional]

+ *

When provided, {@code name}, {@code line_one}, {@code city}, {@code postal_code}, and {@code country} are required within this object.

+ */ + @SerializedName("billing_address") + private DelegatePaymentBillingAddress billingAddress; + + /** + * Risk assessment signals from the platform for fraud decisioning. + *

[Required]

+ *

Type: array of object; each element: {@code type} [Required] (string), {@code score} [Required] (integer), {@code action} [Required] (string).

+ */ + @SerializedName("risk_signals") + private List riskSignals; + + /** + * Key-value metadata attached to the delegated payment request (string values only). + *

[Required]

+ *

Type: object with string values ({@code additionalProperties}: string).

+ */ + private Map metadata; +} diff --git a/src/main/java/com/checkout/agenticcommerce/request/RiskSignal.java b/src/main/java/com/checkout/agenticcommerce/request/RiskSignal.java new file mode 100644 index 00000000..30b3dc9b --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/request/RiskSignal.java @@ -0,0 +1,37 @@ +package com.checkout.agenticcommerce.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A risk assessment signal provided by the platform to support fraud decisioning. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RiskSignal { + + /** + * Risk signal type identifier. + *

+ * [Required] + */ + private String type; + + /** + * Numeric risk score for this signal. + *

+ * [Required] + */ + private Integer score; + + /** + * Action recommended or taken from the risk assessment. + *

+ * [Required] + */ + private String action; +} diff --git a/src/main/java/com/checkout/agenticcommerce/response/DelegatePaymentResponse.java b/src/main/java/com/checkout/agenticcommerce/response/DelegatePaymentResponse.java new file mode 100644 index 00000000..d105419e --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/response/DelegatePaymentResponse.java @@ -0,0 +1,41 @@ +package com.checkout.agenticcommerce.response; + +import com.checkout.HttpMetadata; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.Instant; +import java.util.Map; + +/** + * The response returned when a delegated payment token is successfully created. + */ +@Data +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +public class DelegatePaymentResponse extends HttpMetadata { + + /** + * Unique identifier of the provisioned delegated payment token. + *

+ * [Required] + */ + private String id; + + /** + * Timestamp when the token was created. + *

+ * [Required] + *

+ * Format: date-time (RFC 3339) + */ + private Instant created; + + /** + * Response metadata (string values only). + *

+ * [Required] + */ + private Map metadata; +} diff --git a/src/test/java/com/checkout/agenticcommerce/AgenticCommerceClientImplTest.java b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceClientImplTest.java new file mode 100644 index 00000000..e12780d6 --- /dev/null +++ b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceClientImplTest.java @@ -0,0 +1,199 @@ +package com.checkout.agenticcommerce; + +import com.checkout.ApiClient; +import com.checkout.CheckoutConfiguration; +import com.checkout.SdkAuthorization; +import com.checkout.SdkAuthorizationType; +import com.checkout.SdkCredentials; +import com.checkout.agenticcommerce.request.AllowanceReason; +import com.checkout.agenticcommerce.request.CardFundingType; +import com.checkout.agenticcommerce.request.CardNumberType; +import com.checkout.agenticcommerce.request.DelegatePaymentAllowance; +import com.checkout.agenticcommerce.request.DelegatePaymentBillingAddress; +import com.checkout.agenticcommerce.request.DelegatePaymentMethod; +import com.checkout.agenticcommerce.request.DelegatePaymentRequest; +import com.checkout.agenticcommerce.request.RiskSignal; +import com.checkout.agenticcommerce.response.DelegatePaymentResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class AgenticCommerceClientImplTest { + + private AgenticCommerceClient client; + + @Mock + private ApiClient apiClient; + + @Mock + private CheckoutConfiguration configuration; + + @Mock + private SdkCredentials sdkCredentials; + + @Mock + private SdkAuthorization authorization; + + @BeforeEach + void setUp() { + when(sdkCredentials.getAuthorization(SdkAuthorizationType.SECRET_KEY_OR_OAUTH)).thenReturn(authorization); + when(configuration.getSdkCredentials()).thenReturn(sdkCredentials); + client = new AgenticCommerceClientImpl(apiClient, configuration); + } + + @Test + void shouldDelegatePayment() throws ExecutionException, InterruptedException { + final DelegatePaymentRequest request = buildDelegatePaymentRequest(); + final DelegatePaymentResponse response = mock(DelegatePaymentResponse.class); + + when(apiClient.postAsync( + eq("agentic_commerce/delegate_payment"), + eq(authorization), + eq(DelegatePaymentResponse.class), + eq(request), + isNull())) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.delegatePayment(request); + + assertNotNull(future.get()); + assertEquals(response, future.get()); + } + + @Test + void shouldDelegatePaymentSync() { + final DelegatePaymentRequest request = buildDelegatePaymentRequest(); + final DelegatePaymentResponse response = mock(DelegatePaymentResponse.class); + + when(apiClient.post( + eq("agentic_commerce/delegate_payment"), + eq(authorization), + eq(DelegatePaymentResponse.class), + eq(request), + isNull())) + .thenReturn(response); + + final DelegatePaymentResponse result = client.delegatePaymentSync(request); + + assertNotNull(result); + assertEquals(response, result); + } + + @Test + void shouldDelegatePaymentWithMinimalRequest() throws ExecutionException, InterruptedException { + final DelegatePaymentRequest request = buildMinimalDelegatePaymentRequest(); + final DelegatePaymentResponse response = mock(DelegatePaymentResponse.class); + + when(apiClient.postAsync( + eq("agentic_commerce/delegate_payment"), + eq(authorization), + eq(DelegatePaymentResponse.class), + eq(request), + isNull())) + .thenReturn(CompletableFuture.completedFuture(response)); + + final DelegatePaymentResponse result = client.delegatePayment(request).get(); + + assertNotNull(result); + assertEquals(response, result); + } + + // --- builders --- + + static DelegatePaymentRequest buildDelegatePaymentRequest() { + final DelegatePaymentMethod paymentMethod = DelegatePaymentMethod.builder() + .type("card") + .cardNumberType(CardNumberType.FPAN) + .number("4242424242424242") + .expMonth("11") + .expYear("2026") + .name("Jane Doe") + .cvc("123") + .iin("424242") + .displayCardFundingType(CardFundingType.CREDIT) + .displayBrand("Visa") + .displayLast4("4242") + .checksPerformed(Arrays.asList("avs", "cvv")) + .metadata(Collections.singletonMap("issuing_bank", "test")) + .build(); + + final DelegatePaymentAllowance allowance = DelegatePaymentAllowance.builder() + .reason(AllowanceReason.ONE_TIME) + .maxAmount(10000L) + .currency("USD") + .merchantId("cli_vkuhvk4vjn2edkps7dfsq6emqm") + .checkoutSessionId("1PQrsT") + .expiresAt(Instant.parse("2026-10-09T07:20:50.52Z")) + .build(); + + final DelegatePaymentBillingAddress billingAddress = DelegatePaymentBillingAddress.builder() + .name("John Doe") + .lineOne("123 Fake St.") + .lineTwo("Unit 1") + .city("San Francisco") + .state("CA") + .postalCode("12345") + .country("US") + .build(); + + final RiskSignal riskSignal = RiskSignal.builder() + .type("card_testing") + .score(10) + .action("blocked") + .build(); + + return DelegatePaymentRequest.builder() + .paymentMethod(paymentMethod) + .allowance(allowance) + .billingAddress(billingAddress) + .riskSignals(Collections.singletonList(riskSignal)) + .metadata(Collections.singletonMap("campaign", "q4")) + .build(); + } + + static DelegatePaymentRequest buildMinimalDelegatePaymentRequest() { + final DelegatePaymentMethod paymentMethod = DelegatePaymentMethod.builder() + .type("card") + .cardNumberType(CardNumberType.FPAN) + .number("4242424242424242") + .metadata(Collections.singletonMap("issuing_bank", "test")) + .build(); + + final DelegatePaymentAllowance allowance = DelegatePaymentAllowance.builder() + .reason(AllowanceReason.ONE_TIME) + .maxAmount(5000L) + .currency("EUR") + .merchantId("cli_merchant_123") + .checkoutSessionId("sess_abc") + .expiresAt(Instant.parse("2027-01-01T00:00:00Z")) + .build(); + + final RiskSignal riskSignal = RiskSignal.builder() + .type("velocity") + .score(5) + .action("allow") + .build(); + + return DelegatePaymentRequest.builder() + .paymentMethod(paymentMethod) + .allowance(allowance) + .riskSignals(Collections.singletonList(riskSignal)) + .build(); + } +} diff --git a/src/test/java/com/checkout/agenticcommerce/AgenticCommerceSerializationTest.java b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceSerializationTest.java new file mode 100644 index 00000000..b0625468 --- /dev/null +++ b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceSerializationTest.java @@ -0,0 +1,288 @@ +package com.checkout.agenticcommerce; + +import com.checkout.GsonSerializer; +import com.checkout.agenticcommerce.request.AllowanceReason; +import com.checkout.agenticcommerce.request.CardFundingType; +import com.checkout.agenticcommerce.request.CardNumberType; +import com.checkout.agenticcommerce.request.DelegatePaymentAllowance; +import com.checkout.agenticcommerce.request.DelegatePaymentBillingAddress; +import com.checkout.agenticcommerce.request.DelegatePaymentMethod; +import com.checkout.agenticcommerce.request.DelegatePaymentRequest; +import com.checkout.agenticcommerce.request.RiskSignal; +import com.checkout.agenticcommerce.response.DelegatePaymentResponse; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for GSON serialization/deserialization of Agentic Commerce request and response objects. + * Validates snake_case field naming, enum serialization, and Instant handling. + */ +class AgenticCommerceSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + @Test + void shouldSerializeDelegatePaymentRequestWithCorrectFieldNames() { + final DelegatePaymentRequest request = buildFullRequest(); + + assertDoesNotThrow(() -> { + final String json = serializer.toJson(request); + + assertNotNull(json); + // Top-level fields + assertTrue(json.contains("\"payment_method\""), "Should contain payment_method"); + assertTrue(json.contains("\"allowance\""), "Should contain allowance"); + assertTrue(json.contains("\"billing_address\""), "Should contain billing_address"); + assertTrue(json.contains("\"risk_signals\""), "Should contain risk_signals"); + assertTrue(json.contains("\"metadata\""), "Should contain metadata"); + + // payment_method fields + assertTrue(json.contains("\"card_number_type\""), "Should contain card_number_type"); + assertTrue(json.contains("\"fpan\""), "Should serialize CardNumberType.FPAN as fpan"); + assertTrue(json.contains("\"exp_month\""), "Should contain exp_month"); + assertTrue(json.contains("\"exp_year\""), "Should contain exp_year"); + assertTrue(json.contains("\"checks_performed\""), "Should contain checks_performed"); + assertTrue(json.contains("\"display_card_funding_type\""), "Should contain display_card_funding_type"); + assertTrue(json.contains("\"credit\""), "Should serialize CardFundingType.CREDIT as credit"); + assertTrue(json.contains("\"display_brand\""), "Should contain display_brand"); + assertTrue(json.contains("\"display_last4\""), "Should contain display_last4"); + + // allowance fields + assertTrue(json.contains("\"max_amount\""), "Should contain max_amount"); + assertTrue(json.contains("\"merchant_id\""), "Should contain merchant_id"); + assertTrue(json.contains("\"checkout_session_id\""), "Should contain checkout_session_id"); + assertTrue(json.contains("\"expires_at\""), "Should contain expires_at"); + assertTrue(json.contains("\"one_time\""), "Should serialize AllowanceReason.ONE_TIME as one_time"); + + // billing_address fields + assertTrue(json.contains("\"line_one\""), "Should contain line_one"); + assertTrue(json.contains("\"postal_code\""), "Should contain postal_code"); + }); + } + + @Test + void shouldSerializeNetworkTokenCardNumberType() { + final DelegatePaymentMethod method = DelegatePaymentMethod.builder() + .type("card") + .cardNumberType(CardNumberType.NETWORK_TOKEN) + .number("4242424242424242") + .metadata(Collections.emptyMap()) + .build(); + + final String json = serializer.toJson(method); + + assertTrue(json.contains("\"network_token\""), "Should serialize NETWORK_TOKEN as network_token"); + } + + @Test + void shouldSerializeAllCardFundingTypes() { + for (final CardFundingType type : CardFundingType.values()) { + final DelegatePaymentMethod method = DelegatePaymentMethod.builder() + .type("card") + .cardNumberType(CardNumberType.FPAN) + .number("4111111111111111") + .displayCardFundingType(type) + .metadata(Collections.emptyMap()) + .build(); + + final String json = serializer.toJson(method); + assertNotNull(json); + assertTrue(json.contains(type.name().toLowerCase()), "Should serialize " + type + " as lowercase"); + } + } + + @Test + void shouldDeserializeDelegatePaymentResponse() { + final String json = "{" + + "\"id\":\"vt_abc123def456ghi789\"," + + "\"created\":\"2026-03-11T10:30:00Z\"," + + "\"metadata\":{\"psp\":\"checkout.com\"}" + + "}"; + + final DelegatePaymentResponse response = serializer.fromJson(json, DelegatePaymentResponse.class); + + assertNotNull(response); + assertEquals("vt_abc123def456ghi789", response.getId()); + assertNotNull(response.getCreated()); + assertEquals(Instant.parse("2026-03-11T10:30:00Z"), response.getCreated()); + assertNotNull(response.getMetadata()); + assertEquals("checkout.com", response.getMetadata().get("psp")); + } + + @Test + void shouldDeserializeResponseWithoutMetadata() { + final String json = "{" + + "\"id\":\"vt_xyz\"," + + "\"created\":\"2026-01-01T00:00:00Z\"" + + "}"; + + final DelegatePaymentResponse response = serializer.fromJson(json, DelegatePaymentResponse.class); + + assertNotNull(response); + assertEquals("vt_xyz", response.getId()); + assertNotNull(response.getCreated()); + } + + @Test + void shouldSerializeRequestWithoutOptionalFields() { + final DelegatePaymentRequest minimal = DelegatePaymentRequest.builder() + .paymentMethod(DelegatePaymentMethod.builder() + .type("card") + .cardNumberType(CardNumberType.FPAN) + .number("4242424242424242") + .metadata(Collections.emptyMap()) + .build()) + .allowance(DelegatePaymentAllowance.builder() + .reason(AllowanceReason.ONE_TIME) + .maxAmount(1000L) + .currency("USD") + .merchantId("cli_123") + .checkoutSessionId("sess_456") + .expiresAt(Instant.parse("2027-01-01T00:00:00Z")) + .build()) + .riskSignals(Collections.singletonList(RiskSignal.builder() + .type("velocity") + .score(0) + .action("allow") + .build())) + .build(); + + assertDoesNotThrow(() -> { + final String json = serializer.toJson(minimal); + assertNotNull(json); + assertTrue(json.contains("\"payment_method\"")); + assertTrue(json.contains("\"allowance\"")); + assertTrue(json.contains("\"risk_signals\"")); + }); + } + + @Test + void shouldSerializeRiskSignals() { + final RiskSignal signal1 = RiskSignal.builder().type("card_testing").score(10).action("blocked").build(); + final RiskSignal signal2 = RiskSignal.builder().type("velocity").score(5).action("allow").build(); + + final DelegatePaymentRequest request = DelegatePaymentRequest.builder() + .riskSignals(Arrays.asList(signal1, signal2)) + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"card_testing\"")); + assertTrue(json.contains("\"blocked\"")); + assertTrue(json.contains("\"velocity\"")); + assertTrue(json.contains("\"allow\"")); + } + + @Test + void shouldRoundTripDelegatePaymentRequest() { + final DelegatePaymentRequest original = buildFullRequest(); + + final String json = serializer.toJson(original); + final DelegatePaymentRequest deserialized = serializer.fromJson(json, DelegatePaymentRequest.class); + + assertNotNull(deserialized); + // payment_method + assertEquals(original.getPaymentMethod().getType(), deserialized.getPaymentMethod().getType()); + assertEquals(original.getPaymentMethod().getCardNumberType(), deserialized.getPaymentMethod().getCardNumberType()); + assertEquals(original.getPaymentMethod().getNumber(), deserialized.getPaymentMethod().getNumber()); + assertEquals(original.getPaymentMethod().getExpMonth(), deserialized.getPaymentMethod().getExpMonth()); + assertEquals(original.getPaymentMethod().getExpYear(), deserialized.getPaymentMethod().getExpYear()); + assertEquals(original.getPaymentMethod().getName(), deserialized.getPaymentMethod().getName()); + assertEquals(original.getPaymentMethod().getCvc(), deserialized.getPaymentMethod().getCvc()); + assertEquals(original.getPaymentMethod().getIin(), deserialized.getPaymentMethod().getIin()); + assertEquals(original.getPaymentMethod().getDisplayCardFundingType(), deserialized.getPaymentMethod().getDisplayCardFundingType()); + assertEquals(original.getPaymentMethod().getDisplayBrand(), deserialized.getPaymentMethod().getDisplayBrand()); + assertEquals(original.getPaymentMethod().getDisplayLast4(), deserialized.getPaymentMethod().getDisplayLast4()); + assertEquals(original.getPaymentMethod().getChecksPerformed(), deserialized.getPaymentMethod().getChecksPerformed()); + assertEquals(original.getPaymentMethod().getMetadata(), deserialized.getPaymentMethod().getMetadata()); + // allowance + assertEquals(original.getAllowance().getReason(), deserialized.getAllowance().getReason()); + assertEquals(original.getAllowance().getMaxAmount(), deserialized.getAllowance().getMaxAmount()); + assertEquals(original.getAllowance().getCurrency(), deserialized.getAllowance().getCurrency()); + assertEquals(original.getAllowance().getMerchantId(), deserialized.getAllowance().getMerchantId()); + assertEquals(original.getAllowance().getCheckoutSessionId(), deserialized.getAllowance().getCheckoutSessionId()); + // billing_address + assertEquals(original.getBillingAddress().getName(), deserialized.getBillingAddress().getName()); + assertEquals(original.getBillingAddress().getLineOne(), deserialized.getBillingAddress().getLineOne()); + assertEquals(original.getBillingAddress().getLineTwo(), deserialized.getBillingAddress().getLineTwo()); + assertEquals(original.getBillingAddress().getCity(), deserialized.getBillingAddress().getCity()); + assertEquals(original.getBillingAddress().getState(), deserialized.getBillingAddress().getState()); + assertEquals(original.getBillingAddress().getPostalCode(), deserialized.getBillingAddress().getPostalCode()); + assertEquals(original.getBillingAddress().getCountry(), deserialized.getBillingAddress().getCountry()); + // risk_signals + assertEquals(original.getRiskSignals().size(), deserialized.getRiskSignals().size()); + assertEquals(original.getRiskSignals().get(0).getType(), deserialized.getRiskSignals().get(0).getType()); + assertEquals(original.getRiskSignals().get(0).getScore(), deserialized.getRiskSignals().get(0).getScore()); + assertEquals(original.getRiskSignals().get(0).getAction(), deserialized.getRiskSignals().get(0).getAction()); + // metadata + assertEquals(original.getMetadata(), deserialized.getMetadata()); + } + + @Test + void shouldRoundTripDelegatePaymentResponse() { + final DelegatePaymentResponse original = new DelegatePaymentResponse(); + // DelegatePaymentResponse has no builder/setters besides Lombok @Data + final String json = "{\"id\":\"vt_round\",\"created\":\"2026-05-01T12:00:00Z\",\"metadata\":{\"key\":\"val\"}}"; + final DelegatePaymentResponse deserialized = serializer.fromJson(json, DelegatePaymentResponse.class); + + final String reJson = serializer.toJson(deserialized); + final DelegatePaymentResponse reDeserialized = serializer.fromJson(reJson, DelegatePaymentResponse.class); + + assertEquals(deserialized.getId(), reDeserialized.getId()); + assertEquals(deserialized.getCreated(), reDeserialized.getCreated()); + assertEquals(deserialized.getMetadata(), reDeserialized.getMetadata()); + } + + // --- helper --- + + private static DelegatePaymentRequest buildFullRequest() { + return DelegatePaymentRequest.builder() + .paymentMethod(DelegatePaymentMethod.builder() + .type("card") + .cardNumberType(CardNumberType.FPAN) + .number("4242424242424242") + .expMonth("11") + .expYear("2026") + .name("Jane Doe") + .cvc("123") + .iin("424242") + .displayCardFundingType(CardFundingType.CREDIT) + .displayBrand("Visa") + .displayLast4("4242") + .checksPerformed(Arrays.asList("avs", "cvv")) + .metadata(Collections.singletonMap("issuing_bank", "test")) + .build()) + .allowance(DelegatePaymentAllowance.builder() + .reason(AllowanceReason.ONE_TIME) + .maxAmount(10000L) + .currency("USD") + .merchantId("cli_vkuhvk4vjn2edkps7dfsq6emqm") + .checkoutSessionId("1PQrsT") + .expiresAt(Instant.parse("2026-10-09T07:20:50.52Z")) + .build()) + .billingAddress(DelegatePaymentBillingAddress.builder() + .name("John Doe") + .lineOne("123 Fake St.") + .lineTwo("Unit 1") + .city("San Francisco") + .state("CA") + .postalCode("12345") + .country("US") + .build()) + .riskSignals(Collections.singletonList(RiskSignal.builder() + .type("card_testing") + .score(10) + .action("blocked") + .build())) + .metadata(Collections.singletonMap("campaign", "q4")) + .build(); + } +} diff --git a/src/test/java/com/checkout/agenticcommerce/AgenticCommerceTestIT.java b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceTestIT.java new file mode 100644 index 00000000..7b572c1e --- /dev/null +++ b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceTestIT.java @@ -0,0 +1,147 @@ +package com.checkout.agenticcommerce; + +import com.checkout.PlatformType; +import com.checkout.SandboxTestFixture; +import com.checkout.agenticcommerce.request.AllowanceReason; +import com.checkout.agenticcommerce.request.CardFundingType; +import com.checkout.agenticcommerce.request.CardNumberType; +import com.checkout.agenticcommerce.request.DelegatePaymentAllowance; +import com.checkout.agenticcommerce.request.DelegatePaymentBillingAddress; +import com.checkout.agenticcommerce.request.DelegatePaymentMethod; +import com.checkout.agenticcommerce.request.DelegatePaymentRequest; +import com.checkout.agenticcommerce.request.RiskSignal; +import com.checkout.agenticcommerce.response.DelegatePaymentResponse; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Integration tests for the Agentic Commerce Protocol (Beta). + * + *

These tests are {@link Disabled} because the endpoint requires specific + * merchant configuration and live credentials to be enabled for Agentic Commerce. + * Enable them individually once sandbox access is provisioned.

+ */ +public class AgenticCommerceTestIT extends SandboxTestFixture { + + public AgenticCommerceTestIT() { + super(PlatformType.DEFAULT); + } + + @Disabled("Requires Agentic Commerce to be enabled on the sandbox account") + @Test + void shouldCreateDelegatePaymentToken() { + final DelegatePaymentRequest request = buildFullRequest(); + + final DelegatePaymentResponse response = + blocking(() -> checkoutApi.agenticCommerceClient().delegatePayment(request)); + + validateResponse(response); + } + + @Disabled("Requires Agentic Commerce to be enabled on the sandbox account") + @Test + void shouldCreateDelegatePaymentTokenWithNetworkToken() { + final DelegatePaymentMethod paymentMethod = DelegatePaymentMethod.builder() + .type("card") + .cardNumberType(CardNumberType.NETWORK_TOKEN) + .number("4242424242424242") + .expMonth("12") + .expYear("2027") + .cryptogram("gXc5UCLnM6ckD7pjM1TdPA==") + .eciValue("07") + .iin("424242") + .metadata(Collections.singletonMap("issuing_bank", "test")) + .build(); + + final DelegatePaymentRequest request = DelegatePaymentRequest.builder() + .paymentMethod(paymentMethod) + .allowance(buildAllowance()) + .riskSignals(Collections.singletonList(buildRiskSignal())) + .build(); + + final DelegatePaymentResponse response = + blocking(() -> checkoutApi.agenticCommerceClient().delegatePayment(request)); + + validateResponse(response); + } + + @Disabled("Requires Agentic Commerce to be enabled on the sandbox account") + @Test + void shouldCreateDelegatePaymentTokenSync() { + final DelegatePaymentRequest request = buildFullRequest(); + + final DelegatePaymentResponse response = + checkoutApi.agenticCommerceClient().delegatePaymentSync(request); + + validateResponse(response); + } + + // --- helpers --- + + private static DelegatePaymentRequest buildFullRequest() { + final DelegatePaymentMethod paymentMethod = DelegatePaymentMethod.builder() + .type("card") + .cardNumberType(CardNumberType.FPAN) + .number("4242424242424242") + .expMonth("11") + .expYear("2026") + .name("Jane Doe") + .cvc("100") + .iin("424242") + .displayCardFundingType(CardFundingType.CREDIT) + .displayBrand("Visa") + .displayLast4("4242") + .checksPerformed(Arrays.asList("avs", "cvv")) + .metadata(Collections.singletonMap("issuing_bank", "test")) + .build(); + + final DelegatePaymentBillingAddress billingAddress = DelegatePaymentBillingAddress.builder() + .name("John Doe") + .lineOne("123 Fake St.") + .city("San Francisco") + .postalCode("12345") + .country("US") + .build(); + + return DelegatePaymentRequest.builder() + .paymentMethod(paymentMethod) + .allowance(buildAllowance()) + .billingAddress(billingAddress) + .riskSignals(Collections.singletonList(buildRiskSignal())) + .metadata(Collections.singletonMap("campaign", "q4")) + .build(); + } + + private static DelegatePaymentAllowance buildAllowance() { + return DelegatePaymentAllowance.builder() + .reason(AllowanceReason.ONE_TIME) + .maxAmount(10000L) + .currency("USD") + .merchantId("cli_vkuhvk4vjn2edkps7dfsq6emqm") + .checkoutSessionId("1PQrsT") + .expiresAt(Instant.now().plus(1, ChronoUnit.HOURS)) + .build(); + } + + private static RiskSignal buildRiskSignal() { + return RiskSignal.builder() + .type("card_testing") + .score(10) + .action("blocked") + .build(); + } + + private static void validateResponse(final DelegatePaymentResponse response) { + assertNotNull(response); + assertNotNull(response.getId()); + assertNotNull(response.getCreated()); + assertNotNull(response.getMetadata()); + } +} From 0eb602febd106561fac0ba24c5c22a667665dd54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:50:07 +0200 Subject: [PATCH 6/8] feat: wire new API clients in CheckoutApi and update build config Expose AgenticCommerceClient in CheckoutApi/CheckoutApiImpl and update build.gradle with dependency and configuration changes. --- build.gradle | 14 ++++++++++++++ src/main/java/com/checkout/CheckoutApi.java | 7 +++++++ src/main/java/com/checkout/CheckoutApiImpl.java | 7 +++++++ .../java/com/checkout/CheckoutApiImplTest.java | 1 + 4 files changed, 29 insertions(+) diff --git a/build.gradle b/build.gradle index aee1d54a..a43cbb1c 100644 --- a/build.gradle +++ b/build.gradle @@ -62,9 +62,23 @@ jacocoTestReport { dependsOn test reports { xml.required = true + html.required = true + csv.required = false } } +jacocoTestCoverageVerification { + violationRules { + rule { + limit { + minimum = 0.70 + } + } + } +} + +check.dependsOn jacocoTestCoverageVerification + tasks.register('sourcesJar', Jar) { from sourceSets.main.allJava archiveClassifier = 'sources' diff --git a/src/main/java/com/checkout/CheckoutApi.java b/src/main/java/com/checkout/CheckoutApi.java index 824efbd6..0ec65e95 100644 --- a/src/main/java/com/checkout/CheckoutApi.java +++ b/src/main/java/com/checkout/CheckoutApi.java @@ -1,6 +1,7 @@ package com.checkout; import com.checkout.accounts.AccountsClient; +import com.checkout.agenticcommerce.AgenticCommerceClient; import com.checkout.balances.BalancesClient; import com.checkout.customers.CustomersClient; import com.checkout.disputes.DisputesClient; @@ -102,4 +103,10 @@ public interface CheckoutApi extends CheckoutApmApi { StandaloneAccountUpdaterClient standaloneAccountUpdaterClient(); + /** + * Returns the client for the Agentic Commerce Protocol (Beta). + * Use to create delegated payment tokens for AI-agent-initiated payments. + */ + AgenticCommerceClient agenticCommerceClient(); + } diff --git a/src/main/java/com/checkout/CheckoutApiImpl.java b/src/main/java/com/checkout/CheckoutApiImpl.java index 344371ef..6ff3f576 100644 --- a/src/main/java/com/checkout/CheckoutApiImpl.java +++ b/src/main/java/com/checkout/CheckoutApiImpl.java @@ -2,6 +2,8 @@ import com.checkout.accounts.AccountsClient; import com.checkout.accounts.AccountsClientImpl; +import com.checkout.agenticcommerce.AgenticCommerceClient; +import com.checkout.agenticcommerce.AgenticCommerceClientImpl; import com.checkout.balances.BalancesClient; import com.checkout.balances.BalancesClientImpl; import com.checkout.customers.CustomersClient; @@ -104,6 +106,7 @@ public class CheckoutApiImpl extends AbstractCheckoutApmApi implements CheckoutA private final AmlScreeningClient amlScreeningClient; private final NetworkTokensClient networkTokensClient; private final StandaloneAccountUpdaterClient standaloneAccountUpdaterClient; + private final AgenticCommerceClient agenticCommerceClient; public CheckoutApiImpl(final CheckoutConfiguration configuration) { super(configuration); @@ -142,6 +145,7 @@ public CheckoutApiImpl(final CheckoutConfiguration configuration) { this.amlScreeningClient = new AmlScreeningClientImpl(this.apiClient, configuration); this.networkTokensClient = new NetworkTokensClientImpl(this.apiClient, configuration); this.standaloneAccountUpdaterClient = new StandaloneAccountUpdaterClientImpl(this.apiClient, configuration); + this.agenticCommerceClient = new AgenticCommerceClientImpl(this.apiClient, configuration); } @Override @@ -277,6 +281,9 @@ public MetadataClient metadataClient() { @Override public StandaloneAccountUpdaterClient standaloneAccountUpdaterClient() { return standaloneAccountUpdaterClient; } + @Override + public AgenticCommerceClient agenticCommerceClient() { return agenticCommerceClient; } + private ApiClient getFilesClient(final CheckoutConfiguration configuration) { return new ApiClientImpl(configuration, new FilesApiUriStrategy(configuration)); } diff --git a/src/test/java/com/checkout/CheckoutApiImplTest.java b/src/test/java/com/checkout/CheckoutApiImplTest.java index fdedc0ad..e180cca5 100644 --- a/src/test/java/com/checkout/CheckoutApiImplTest.java +++ b/src/test/java/com/checkout/CheckoutApiImplTest.java @@ -36,6 +36,7 @@ void shouldInstantiateAndRetrieveClients() { assertNotNull(checkoutApi.paymentLinksClient()); assertNotNull(checkoutApi.forexClient()); assertNotNull(checkoutApi.metadataClient()); + assertNotNull(checkoutApi.agenticCommerceClient()); // APMs assertNotNull(checkoutApi.idealClient()); } From 21faffce5c0f0933fb5bc37d1d13eadb75b61ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:46:21 +0200 Subject: [PATCH 7/8] feat: extract IHeaders interface and add DelegatePaymentHeaders for Agentic Commerce Extract IHeaders interface from accounts.Headers to allow custom HTTP headers in non-Accounts contexts. Add DelegatePaymentHeaders (Signature, Timestamp, API-Version) for the delegate payment endpoint. Update ApiClient, Transport, and ApacheHttpClientTransport with new post/postAsync overloads accepting IHeaders. Filter Signature header from request logs for security. Includes HeadersReflectionTest and updated unit/integration tests. --- .../checkout/ApacheHttpClientTransport.java | 162 ++++++++---------- src/main/java/com/checkout/ApiClient.java | 4 + src/main/java/com/checkout/ApiClientImpl.java | 27 +++ src/main/java/com/checkout/IHeaders.java | 13 ++ src/main/java/com/checkout/Transport.java | 4 + .../java/com/checkout/accounts/Headers.java | 15 +- .../AgenticCommerceClient.java | 5 +- .../AgenticCommerceClientImpl.java | 15 +- .../request/DelegatePaymentHeaders.java | 44 +++++ .../com/checkout/HeadersReflectionTest.java | 83 +++++++++ .../AgenticCommerceClientImplTest.java | 28 ++- .../AgenticCommerceTestIT.java | 14 +- 12 files changed, 309 insertions(+), 105 deletions(-) create mode 100644 src/main/java/com/checkout/IHeaders.java create mode 100644 src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentHeaders.java create mode 100644 src/test/java/com/checkout/HeadersReflectionTest.java diff --git a/src/main/java/com/checkout/ApacheHttpClientTransport.java b/src/main/java/com/checkout/ApacheHttpClientTransport.java index 8cbef97a..10bfa548 100644 --- a/src/main/java/com/checkout/ApacheHttpClientTransport.java +++ b/src/main/java/com/checkout/ApacheHttpClientTransport.java @@ -1,7 +1,6 @@ package com.checkout; import com.checkout.accounts.AccountsFileRequest; -import com.checkout.accounts.Headers; import com.checkout.common.AbstractFileRequest; import com.checkout.common.CheckoutUtils; import com.checkout.common.FileRequest; @@ -34,7 +33,6 @@ import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; -import java.lang.reflect.Field; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -43,8 +41,6 @@ import java.util.concurrent.Executor; import java.util.stream.Collectors; import java.util.function.Supplier; - -import com.google.gson.annotations.SerializedName; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.ratelimiter.RateLimiter; import io.github.resilience4j.retry.Retry; @@ -101,43 +97,24 @@ public CompletableFuture invoke(final ClientOperation clientOperation, final Object requestObject, final String idempotencyKey, final Map queryParams) { + return invoke(clientOperation, path, authorization, requestObject, idempotencyKey, queryParams, null); + } + + @Override + public CompletableFuture invoke(final ClientOperation clientOperation, + final String path, + final SdkAuthorization authorization, + final Object requestObject, + final String idempotencyKey, + final Map queryParams, + final IHeaders headers) { return CompletableFuture.supplyAsync(() -> { - final HttpUriRequest request; - switch (clientOperation) { - case GET: - case GET_CSV_CONTENT: - request = new HttpGet(getRequestUrl(path)); - break; - case PUT: - request = new HttpPut(getRequestUrl(path)); - break; - case POST: - request = new HttpPost(getRequestUrl(path)); - break; - case DELETE: - request = new HttpDelete(getRequestUrl(path)); - break; - case PATCH: - request = new HttpPatch(getRequestUrl(path)); - break; - case QUERY: - final List params = queryParams.entrySet().stream() - .map(entry -> new BasicNameValuePair(entry.getKey(), entry.getValue())) - .collect(Collectors.toList()); - try { - request = new HttpGet(new URIBuilder(getRequestUrl(path)).addParameters(params).build()); - } catch (final URISyntaxException e) { - throw new CheckoutException(e); - } - break; - default: - throw new UnsupportedOperationException("Unsupported HTTP Method: " + clientOperation); - } + final HttpUriRequest request = buildRequest(clientOperation, path, queryParams); if (idempotencyKey != null) { request.setHeader(CKO_IDEMPOTENCY_KEY, idempotencyKey); } - return performCall(authorization, requestObject, request, clientOperation); + return performCall(authorization, requestObject, request, clientOperation, headers); }, executor); } @@ -157,42 +134,23 @@ public Response invokeSync(final ClientOperation clientOperation, final Object requestObject, final String idempotencyKey, final Map queryParams) { - final HttpUriRequest request; - switch (clientOperation) { - case GET: - case GET_CSV_CONTENT: - request = new HttpGet(getRequestUrl(path)); - break; - case PUT: - request = new HttpPut(getRequestUrl(path)); - break; - case POST: - request = new HttpPost(getRequestUrl(path)); - break; - case DELETE: - request = new HttpDelete(getRequestUrl(path)); - break; - case PATCH: - request = new HttpPatch(getRequestUrl(path)); - break; - case QUERY: - final List params = queryParams.entrySet().stream() - .map(entry -> new BasicNameValuePair(entry.getKey(), entry.getValue())) - .collect(Collectors.toList()); - try { - request = new HttpGet(new URIBuilder(getRequestUrl(path)).addParameters(params).build()); - } catch (final URISyntaxException e) { - throw new CheckoutException(e); - } - break; - default: - throw new UnsupportedOperationException("Unsupported HTTP Method: " + clientOperation); - } + return invokeSync(clientOperation, path, authorization, requestObject, idempotencyKey, queryParams, null); + } + + @Override + public Response invokeSync(final ClientOperation clientOperation, + final String path, + final SdkAuthorization authorization, + final Object requestObject, + final String idempotencyKey, + final Map queryParams, + final IHeaders headers) { + final HttpUriRequest request = buildRequest(clientOperation, path, queryParams); if (idempotencyKey != null) { request.setHeader(CKO_IDEMPOTENCY_KEY, idempotencyKey); } - - final Supplier callSupplier = () -> performCall(authorization, requestObject, request, clientOperation); + + final Supplier callSupplier = () -> performCall(authorization, requestObject, request, clientOperation, headers); return executeWithResilience4j(callSupplier); } @@ -258,13 +216,21 @@ private Response performCall(final SdkAuthorization authorization, final Object requestBody, final HttpUriRequest request, final ClientOperation clientOperation) { + return performCall(authorization, requestBody, request, clientOperation, null); + } + + private Response performCall(final SdkAuthorization authorization, + final Object requestBody, + final HttpUriRequest request, + final ClientOperation clientOperation, + final IHeaders customHeaders) { log.info("{}: {}", clientOperation, request.getURI()); request.setHeader(USER_AGENT, PROJECT_NAME + "/" + getVersionFromManifest()); request.setHeader(ACCEPT, getAcceptHeader(clientOperation)); request.setHeader(AUTHORIZATION, authorization.getAuthorizationHeader()); - // Check and add headers from the request object if needed addHeadersFromRequestBody(requestBody, request); + applyHeaders(customHeaders, request); String currentRequestId = UUID.randomUUID().toString(); @@ -318,19 +284,17 @@ private Response performCall(final SdkAuthorization authorization, } private void addHeadersFromRequestBody(final Object requestBody, final HttpUriRequest request) { - if (requestBody instanceof Headers) { - Headers headers = (Headers) requestBody; - for (Field field : Headers.class.getDeclaredFields()) { - field.setAccessible(true); - try { - Object value = field.get(headers); - if (value != null && !value.toString().isEmpty()) { - SerializedName serializedName = field.getAnnotation(SerializedName.class); - String headerName = serializedName != null ? serializedName.value() : field.getName(); - request.setHeader(headerName, value.toString()); - } - } catch (IllegalAccessException ignored) {} - } + if (requestBody instanceof IHeaders) { + applyHeaders((IHeaders) requestBody, request); + } + } + + private void applyHeaders(final IHeaders headers, final HttpUriRequest request) { + if (headers == null) { + return; + } + for (Map.Entry entry : headers.getHeaders().entrySet()) { + request.setHeader(entry.getKey(), entry.getValue()); } } @@ -363,12 +327,9 @@ private Response handleException(Exception e, String errorMessage) { } private Header[] sanitiseHeaders(final Header[] headers) { - // TODO: discuss whether Cko-Idempotency-Key should also be filtered — it is a per-request - // unique identifier (not a credential), but filtering it reduces exposure in INFO logs. - // Uncomment the line below once agreed. - // .filter(it -> !it.getName().equalsIgnoreCase(CKO_IDEMPOTENCY_KEY)) return Arrays.stream(headers) .filter(it -> !it.getName().equals(AUTHORIZATION)) + .filter(it -> !it.getName().equalsIgnoreCase("Signature")) .toArray(Header[]::new); } @@ -388,6 +349,33 @@ private String getAcceptHeader(final ClientOperation clientOperation) { } } + private HttpUriRequest buildRequest(final ClientOperation clientOperation, final String path, final Map queryParams) { + switch (clientOperation) { + case GET: + case GET_CSV_CONTENT: + return new HttpGet(getRequestUrl(path)); + case PUT: + return new HttpPut(getRequestUrl(path)); + case POST: + return new HttpPost(getRequestUrl(path)); + case DELETE: + return new HttpDelete(getRequestUrl(path)); + case PATCH: + return new HttpPatch(getRequestUrl(path)); + case QUERY: + final List params = queryParams.entrySet().stream() + .map(entry -> new BasicNameValuePair(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + try { + return new HttpGet(new URIBuilder(getRequestUrl(path)).addParameters(params).build()); + } catch (final URISyntaxException e) { + throw new CheckoutException(e); + } + default: + throw new UnsupportedOperationException("Unsupported HTTP Method: " + clientOperation); + } + } + private String getRequestUrl(final String path) { try { return baseUri.resolve(path).toURL().toString(); diff --git a/src/main/java/com/checkout/ApiClient.java b/src/main/java/com/checkout/ApiClient.java index 51502377..4a3b6390 100644 --- a/src/main/java/com/checkout/ApiClient.java +++ b/src/main/java/com/checkout/ApiClient.java @@ -16,6 +16,8 @@ public interface ApiClient { CompletableFuture postAsync(String path, SdkAuthorization authorization, Class responseType, Object request, String idempotencyKey); + CompletableFuture postAsync(String path, SdkAuthorization authorization, Class responseType, Object request, String idempotencyKey, IHeaders headers); + CompletableFuture patchAsync(String path, SdkAuthorization authorization, Type type, Object request, String idempotencyKey); CompletableFuture postAsync(String path, SdkAuthorization authorization, Type responseType, Object request, String idempotencyKey); @@ -47,6 +49,8 @@ public interface ApiClient { T post(String path, SdkAuthorization authorization, Class responseType, Object request, String idempotencyKey); + T post(String path, SdkAuthorization authorization, Class responseType, Object request, String idempotencyKey, IHeaders headers); + T post(String path, SdkAuthorization authorization, Type responseType, Object request, String idempotencyKey); EmptyResponse delete(String path, SdkAuthorization authorization); diff --git a/src/main/java/com/checkout/ApiClientImpl.java b/src/main/java/com/checkout/ApiClientImpl.java index 35e8c8d8..ff20b542 100644 --- a/src/main/java/com/checkout/ApiClientImpl.java +++ b/src/main/java/com/checkout/ApiClientImpl.java @@ -95,6 +95,15 @@ public CompletableFuture postAsync(final String path ); } + @Override + public CompletableFuture postAsync(final String path, final SdkAuthorization authorization, final Class responseType, final Object request, final String idempotencyKey, final IHeaders headers) { + validateParams(PATH, path, AUTHORIZATION, authorization); + return executeAsyncOrSync( + () -> post(path, authorization, responseType, request, idempotencyKey, headers), + () -> sendRequestAsync(POST, path, authorization, request, idempotencyKey, responseType, headers) + ); + } + @Override public CompletableFuture postAsync(final String path, final SdkAuthorization authorization, final Type responseType, final Object request, final String idempotencyKey) { validateParams(PATH, path, AUTHORIZATION, authorization); @@ -236,6 +245,12 @@ private CompletableFuture sendRequestAsync(final Cli .thenApply(response -> deserialize(response, responseType)); } + private CompletableFuture sendRequestAsync(final ClientOperation clientOperation, final String path, final SdkAuthorization authorization, final Object request, final String idempotencyKey, final Type responseType, final IHeaders headers) { + return transport.invoke(clientOperation, path, authorization, request, idempotencyKey, null, headers) + .thenApply(this::errorCheck) + .thenApply(response -> deserialize(response, responseType)); + } + private Response errorCheck(final Response response) { if (!CheckoutUtils.isSuccessHttpStatusCode(response.getStatusCode())) { Map errorDetails = null; @@ -335,6 +350,12 @@ public T post(final String path, final SdkAuthorization return sendRequestSync(POST, path, authorization, request, idempotencyKey, responseType); } + @Override + public T post(final String path, final SdkAuthorization authorization, final Class responseType, final Object request, final String idempotencyKey, final IHeaders headers) { + validateParams(PATH, path, AUTHORIZATION, authorization); + return sendRequestSync(POST, path, authorization, request, idempotencyKey, responseType, headers); + } + @Override public T post(final String path, final SdkAuthorization authorization, final Type responseType, final Object request, final String idempotencyKey) { validateParams(PATH, path, AUTHORIZATION, authorization); @@ -411,4 +432,10 @@ private T sendRequestSync(final ClientOperation clientO return deserialize(checkedResponse, responseType); } + private T sendRequestSync(final ClientOperation clientOperation, final String path, final SdkAuthorization authorization, final Object request, final String idempotencyKey, final Type responseType, final IHeaders headers) { + final Response response = transport.invokeSync(clientOperation, path, authorization, request, idempotencyKey, null, headers); + final Response checkedResponse = errorCheck(response); + return deserialize(checkedResponse, responseType); + } + } diff --git a/src/main/java/com/checkout/IHeaders.java b/src/main/java/com/checkout/IHeaders.java new file mode 100644 index 00000000..e6bc71ea --- /dev/null +++ b/src/main/java/com/checkout/IHeaders.java @@ -0,0 +1,13 @@ +package com.checkout; + +import java.util.Map; + +/** + * Common interface for custom HTTP headers that can be passed alongside API requests. + * Implementations provide endpoint-specific headers (e.g., Agentic Commerce signatures, + * authentication session headers). + */ +public interface IHeaders { + + Map getHeaders(); +} diff --git a/src/main/java/com/checkout/Transport.java b/src/main/java/com/checkout/Transport.java index 3a1da5a8..c51c9833 100644 --- a/src/main/java/com/checkout/Transport.java +++ b/src/main/java/com/checkout/Transport.java @@ -9,10 +9,14 @@ public interface Transport { CompletableFuture invoke(ClientOperation clientOperation, String path, SdkAuthorization authorization, Object requestObject, String idempotencyKey, Map queryParams); + CompletableFuture invoke(ClientOperation clientOperation, String path, SdkAuthorization authorization, Object requestObject, String idempotencyKey, Map queryParams, IHeaders headers); + CompletableFuture submitFile(String path, SdkAuthorization authorization, AbstractFileRequest fileRequest); Response invokeSync(ClientOperation clientOperation, String path, SdkAuthorization authorization, Object requestObject, String idempotencyKey, Map queryParams); + Response invokeSync(ClientOperation clientOperation, String path, SdkAuthorization authorization, Object requestObject, String idempotencyKey, Map queryParams, IHeaders headers); + Response submitFileSync(String path, SdkAuthorization authorization, AbstractFileRequest fileRequest); } diff --git a/src/main/java/com/checkout/accounts/Headers.java b/src/main/java/com/checkout/accounts/Headers.java index 529d0883..9dce71ab 100644 --- a/src/main/java/com/checkout/accounts/Headers.java +++ b/src/main/java/com/checkout/accounts/Headers.java @@ -1,5 +1,6 @@ package com.checkout.accounts; +import com.checkout.IHeaders; import com.google.gson.annotations.SerializedName; import lombok.AllArgsConstructor; @@ -7,12 +8,24 @@ import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; +import java.util.LinkedHashMap; +import java.util.Map; + @Data @SuperBuilder @NoArgsConstructor @AllArgsConstructor -public class Headers { +public class Headers implements IHeaders { @SerializedName("if-match") private String ifMatch; + + @Override + public Map getHeaders() { + final Map headers = new LinkedHashMap<>(); + if (ifMatch != null) { + headers.put("if-match", ifMatch); + } + return headers; + } } diff --git a/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClient.java b/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClient.java index 8f90935f..fcbafc4e 100644 --- a/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClient.java +++ b/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClient.java @@ -1,5 +1,6 @@ package com.checkout.agenticcommerce; +import com.checkout.agenticcommerce.request.DelegatePaymentHeaders; import com.checkout.agenticcommerce.request.DelegatePaymentRequest; import com.checkout.agenticcommerce.response.DelegatePaymentResponse; @@ -7,7 +8,7 @@ public interface AgenticCommerceClient { - CompletableFuture delegatePayment(DelegatePaymentRequest request); + CompletableFuture delegatePayment(DelegatePaymentRequest request, DelegatePaymentHeaders headers); - DelegatePaymentResponse delegatePaymentSync(DelegatePaymentRequest request); + DelegatePaymentResponse delegatePaymentSync(DelegatePaymentRequest request, DelegatePaymentHeaders headers); } diff --git a/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClientImpl.java b/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClientImpl.java index 7957976e..08a4b302 100644 --- a/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClientImpl.java +++ b/src/main/java/com/checkout/agenticcommerce/AgenticCommerceClientImpl.java @@ -4,6 +4,7 @@ import com.checkout.ApiClient; import com.checkout.CheckoutConfiguration; import com.checkout.SdkAuthorizationType; +import com.checkout.agenticcommerce.request.DelegatePaymentHeaders; import com.checkout.agenticcommerce.request.DelegatePaymentRequest; import com.checkout.agenticcommerce.response.DelegatePaymentResponse; import com.checkout.common.CheckoutUtils; @@ -20,24 +21,26 @@ public AgenticCommerceClientImpl(final ApiClient apiClient, final CheckoutConfig } @Override - public CompletableFuture delegatePayment(final DelegatePaymentRequest request) { - CheckoutUtils.validateParams("request", request); + public CompletableFuture delegatePayment(final DelegatePaymentRequest request, final DelegatePaymentHeaders headers) { + CheckoutUtils.validateParams("request", request, "headers", headers); return apiClient.postAsync( buildPath(AGENTIC_COMMERCE_PATH, DELEGATE_PAYMENT_PATH), sdkAuthorization(), DelegatePaymentResponse.class, request, - null); + null, + headers); } @Override - public DelegatePaymentResponse delegatePaymentSync(final DelegatePaymentRequest request) { - CheckoutUtils.validateParams("request", request); + public DelegatePaymentResponse delegatePaymentSync(final DelegatePaymentRequest request, final DelegatePaymentHeaders headers) { + CheckoutUtils.validateParams("request", request, "headers", headers); return apiClient.post( buildPath(AGENTIC_COMMERCE_PATH, DELEGATE_PAYMENT_PATH), sdkAuthorization(), DelegatePaymentResponse.class, request, - null); + null, + headers); } } diff --git a/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentHeaders.java b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentHeaders.java new file mode 100644 index 00000000..db320eb0 --- /dev/null +++ b/src/main/java/com/checkout/agenticcommerce/request/DelegatePaymentHeaders.java @@ -0,0 +1,44 @@ +package com.checkout.agenticcommerce.request; + +import com.checkout.IHeaders; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Custom HTTP headers required by the Agentic Commerce delegate payment endpoint. + * + * @see POST /agentic_commerce/delegate_payment + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DelegatePaymentHeaders implements IHeaders { + + private String signature; + + private String timestamp; + + @Builder.Default + private String apiVersion = null; + + @Override + public Map getHeaders() { + final Map headers = new LinkedHashMap<>(); + if (signature != null) { + headers.put("Signature", signature); + } + if (timestamp != null) { + headers.put("Timestamp", timestamp); + } + if (apiVersion != null) { + headers.put("API-Version", apiVersion); + } + return headers; + } +} diff --git a/src/test/java/com/checkout/HeadersReflectionTest.java b/src/test/java/com/checkout/HeadersReflectionTest.java new file mode 100644 index 00000000..9f54a641 --- /dev/null +++ b/src/test/java/com/checkout/HeadersReflectionTest.java @@ -0,0 +1,83 @@ +package com.checkout; + +import com.checkout.accounts.Headers; +import com.checkout.agenticcommerce.request.DelegatePaymentHeaders; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validates that all {@link IHeaders} implementations produce correct header maps. + */ +class HeadersReflectionTest { + + @Test + void accountsHeadersShouldProduceCorrectHeaders() { + final Headers headers = Headers.builder() + .ifMatch("etag-value") + .build(); + + final Map map = headers.getHeaders(); + + assertNotNull(map); + assertEquals(1, map.size()); + assertEquals("etag-value", map.get("if-match")); + } + + @Test + void accountsHeadersShouldOmitNullValues() { + final Headers headers = Headers.builder().build(); + + final Map map = headers.getHeaders(); + + assertNotNull(map); + assertTrue(map.isEmpty()); + } + + @Test + void delegatePaymentHeadersShouldProduceAllHeaders() { + final DelegatePaymentHeaders headers = DelegatePaymentHeaders.builder() + .signature("sha256=abc123") + .timestamp("2026-04-08T12:00:00Z") + .apiVersion("2025-01-01") + .build(); + + final Map map = headers.getHeaders(); + + assertNotNull(map); + assertEquals(3, map.size()); + assertEquals("sha256=abc123", map.get("Signature")); + assertEquals("2026-04-08T12:00:00Z", map.get("Timestamp")); + assertEquals("2025-01-01", map.get("API-Version")); + } + + @Test + void delegatePaymentHeadersShouldOmitOptionalApiVersion() { + final DelegatePaymentHeaders headers = DelegatePaymentHeaders.builder() + .signature("sha256=abc123") + .timestamp("2026-04-08T12:00:00Z") + .build(); + + final Map map = headers.getHeaders(); + + assertNotNull(map); + assertEquals(2, map.size()); + assertEquals("sha256=abc123", map.get("Signature")); + assertEquals("2026-04-08T12:00:00Z", map.get("Timestamp")); + assertTrue(!map.containsKey("API-Version")); + } + + @Test + void delegatePaymentHeadersShouldOmitAllNullValues() { + final DelegatePaymentHeaders headers = DelegatePaymentHeaders.builder().build(); + + final Map map = headers.getHeaders(); + + assertNotNull(map); + assertTrue(map.isEmpty()); + } +} diff --git a/src/test/java/com/checkout/agenticcommerce/AgenticCommerceClientImplTest.java b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceClientImplTest.java index e12780d6..d54ff66e 100644 --- a/src/test/java/com/checkout/agenticcommerce/AgenticCommerceClientImplTest.java +++ b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceClientImplTest.java @@ -10,6 +10,7 @@ import com.checkout.agenticcommerce.request.CardNumberType; import com.checkout.agenticcommerce.request.DelegatePaymentAllowance; import com.checkout.agenticcommerce.request.DelegatePaymentBillingAddress; +import com.checkout.agenticcommerce.request.DelegatePaymentHeaders; import com.checkout.agenticcommerce.request.DelegatePaymentMethod; import com.checkout.agenticcommerce.request.DelegatePaymentRequest; import com.checkout.agenticcommerce.request.RiskSignal; @@ -28,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; @@ -60,6 +62,7 @@ void setUp() { @Test void shouldDelegatePayment() throws ExecutionException, InterruptedException { final DelegatePaymentRequest request = buildDelegatePaymentRequest(); + final DelegatePaymentHeaders headers = buildHeaders(); final DelegatePaymentResponse response = mock(DelegatePaymentResponse.class); when(apiClient.postAsync( @@ -67,10 +70,11 @@ void shouldDelegatePayment() throws ExecutionException, InterruptedException { eq(authorization), eq(DelegatePaymentResponse.class), eq(request), - isNull())) + isNull(), + eq(headers))) .thenReturn(CompletableFuture.completedFuture(response)); - final CompletableFuture future = client.delegatePayment(request); + final CompletableFuture future = client.delegatePayment(request, headers); assertNotNull(future.get()); assertEquals(response, future.get()); @@ -79,6 +83,7 @@ void shouldDelegatePayment() throws ExecutionException, InterruptedException { @Test void shouldDelegatePaymentSync() { final DelegatePaymentRequest request = buildDelegatePaymentRequest(); + final DelegatePaymentHeaders headers = buildHeaders(); final DelegatePaymentResponse response = mock(DelegatePaymentResponse.class); when(apiClient.post( @@ -86,10 +91,11 @@ void shouldDelegatePaymentSync() { eq(authorization), eq(DelegatePaymentResponse.class), eq(request), - isNull())) + isNull(), + eq(headers))) .thenReturn(response); - final DelegatePaymentResponse result = client.delegatePaymentSync(request); + final DelegatePaymentResponse result = client.delegatePaymentSync(request, headers); assertNotNull(result); assertEquals(response, result); @@ -98,6 +104,7 @@ void shouldDelegatePaymentSync() { @Test void shouldDelegatePaymentWithMinimalRequest() throws ExecutionException, InterruptedException { final DelegatePaymentRequest request = buildMinimalDelegatePaymentRequest(); + final DelegatePaymentHeaders headers = buildHeaders(); final DelegatePaymentResponse response = mock(DelegatePaymentResponse.class); when(apiClient.postAsync( @@ -105,10 +112,11 @@ void shouldDelegatePaymentWithMinimalRequest() throws ExecutionException, Interr eq(authorization), eq(DelegatePaymentResponse.class), eq(request), - isNull())) + isNull(), + any(DelegatePaymentHeaders.class))) .thenReturn(CompletableFuture.completedFuture(response)); - final DelegatePaymentResponse result = client.delegatePayment(request).get(); + final DelegatePaymentResponse result = client.delegatePayment(request, headers).get(); assertNotNull(result); assertEquals(response, result); @@ -116,6 +124,14 @@ void shouldDelegatePaymentWithMinimalRequest() throws ExecutionException, Interr // --- builders --- + static DelegatePaymentHeaders buildHeaders() { + return DelegatePaymentHeaders.builder() + .signature("sha256=abc123def456") + .timestamp("2026-04-08T12:00:00Z") + .apiVersion("2025-01-01") + .build(); + } + static DelegatePaymentRequest buildDelegatePaymentRequest() { final DelegatePaymentMethod paymentMethod = DelegatePaymentMethod.builder() .type("card") diff --git a/src/test/java/com/checkout/agenticcommerce/AgenticCommerceTestIT.java b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceTestIT.java index 7b572c1e..3b7ce249 100644 --- a/src/test/java/com/checkout/agenticcommerce/AgenticCommerceTestIT.java +++ b/src/test/java/com/checkout/agenticcommerce/AgenticCommerceTestIT.java @@ -9,6 +9,7 @@ import com.checkout.agenticcommerce.request.DelegatePaymentBillingAddress; import com.checkout.agenticcommerce.request.DelegatePaymentMethod; import com.checkout.agenticcommerce.request.DelegatePaymentRequest; +import com.checkout.agenticcommerce.request.DelegatePaymentHeaders; import com.checkout.agenticcommerce.request.RiskSignal; import com.checkout.agenticcommerce.response.DelegatePaymentResponse; import org.junit.jupiter.api.Disabled; @@ -40,7 +41,7 @@ void shouldCreateDelegatePaymentToken() { final DelegatePaymentRequest request = buildFullRequest(); final DelegatePaymentResponse response = - blocking(() -> checkoutApi.agenticCommerceClient().delegatePayment(request)); + blocking(() -> checkoutApi.agenticCommerceClient().delegatePayment(request, buildHeaders())); validateResponse(response); } @@ -67,7 +68,7 @@ void shouldCreateDelegatePaymentTokenWithNetworkToken() { .build(); final DelegatePaymentResponse response = - blocking(() -> checkoutApi.agenticCommerceClient().delegatePayment(request)); + blocking(() -> checkoutApi.agenticCommerceClient().delegatePayment(request, buildHeaders())); validateResponse(response); } @@ -78,13 +79,20 @@ void shouldCreateDelegatePaymentTokenSync() { final DelegatePaymentRequest request = buildFullRequest(); final DelegatePaymentResponse response = - checkoutApi.agenticCommerceClient().delegatePaymentSync(request); + checkoutApi.agenticCommerceClient().delegatePaymentSync(request, buildHeaders()); validateResponse(response); } // --- helpers --- + private static DelegatePaymentHeaders buildHeaders() { + return DelegatePaymentHeaders.builder() + .signature("sha256=test_signature") + .timestamp(Instant.now().toString()) + .build(); + } + private static DelegatePaymentRequest buildFullRequest() { final DelegatePaymentMethod paymentMethod = DelegatePaymentMethod.builder() .type("card") From 4890ba2a5a8a7008e9657f38a6a88c40e5f9047e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:52:51 +0200 Subject: [PATCH 8/8] refactor: centralize idempotencyKey header in buildRequest method Move the duplicated idempotencyKey header logic from invoke() and invokeSync() into buildRequest(), eliminating code duplication and keeping request construction in a single place. --- .../checkout/ApacheHttpClientTransport.java | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/checkout/ApacheHttpClientTransport.java b/src/main/java/com/checkout/ApacheHttpClientTransport.java index 10bfa548..588fca2e 100644 --- a/src/main/java/com/checkout/ApacheHttpClientTransport.java +++ b/src/main/java/com/checkout/ApacheHttpClientTransport.java @@ -110,10 +110,7 @@ public CompletableFuture invoke(final ClientOperation clientOperation, final IHeaders headers) { return CompletableFuture.supplyAsync(() -> { - final HttpUriRequest request = buildRequest(clientOperation, path, queryParams); - if (idempotencyKey != null) { - request.setHeader(CKO_IDEMPOTENCY_KEY, idempotencyKey); - } + final HttpUriRequest request = buildRequest(clientOperation, path, queryParams, idempotencyKey); return performCall(authorization, requestObject, request, clientOperation, headers); }, executor); } @@ -145,10 +142,7 @@ public Response invokeSync(final ClientOperation clientOperation, final String idempotencyKey, final Map queryParams, final IHeaders headers) { - final HttpUriRequest request = buildRequest(clientOperation, path, queryParams); - if (idempotencyKey != null) { - request.setHeader(CKO_IDEMPOTENCY_KEY, idempotencyKey); - } + final HttpUriRequest request = buildRequest(clientOperation, path, queryParams, idempotencyKey); final Supplier callSupplier = () -> performCall(authorization, requestObject, request, clientOperation, headers); return executeWithResilience4j(callSupplier); @@ -349,31 +343,42 @@ private String getAcceptHeader(final ClientOperation clientOperation) { } } - private HttpUriRequest buildRequest(final ClientOperation clientOperation, final String path, final Map queryParams) { + private HttpUriRequest buildRequest(final ClientOperation clientOperation, final String path, final Map queryParams, final String idempotencyKey) { + final HttpUriRequest request; switch (clientOperation) { case GET: case GET_CSV_CONTENT: - return new HttpGet(getRequestUrl(path)); + request = new HttpGet(getRequestUrl(path)); + break; case PUT: - return new HttpPut(getRequestUrl(path)); + request = new HttpPut(getRequestUrl(path)); + break; case POST: - return new HttpPost(getRequestUrl(path)); + request = new HttpPost(getRequestUrl(path)); + break; case DELETE: - return new HttpDelete(getRequestUrl(path)); + request = new HttpDelete(getRequestUrl(path)); + break; case PATCH: - return new HttpPatch(getRequestUrl(path)); + request = new HttpPatch(getRequestUrl(path)); + break; case QUERY: final List params = queryParams.entrySet().stream() .map(entry -> new BasicNameValuePair(entry.getKey(), entry.getValue())) .collect(Collectors.toList()); try { - return new HttpGet(new URIBuilder(getRequestUrl(path)).addParameters(params).build()); + request = new HttpGet(new URIBuilder(getRequestUrl(path)).addParameters(params).build()); } catch (final URISyntaxException e) { throw new CheckoutException(e); } + break; default: throw new UnsupportedOperationException("Unsupported HTTP Method: " + clientOperation); } + if (idempotencyKey != null) { + request.setHeader(CKO_IDEMPOTENCY_KEY, idempotencyKey); + } + return request; } private String getRequestUrl(final String path) {