From 8bd31745fae08dbe21ff3d5907f8a4c96c2368ac Mon Sep 17 00:00:00 2001 From: david ruiz Date: Mon, 7 Sep 2026 13:15:52 +0200 Subject: [PATCH 1/2] Top-up instructions and amount allocations --- src/main/java/com/checkout/OAuthScope.java | 1 + .../java/com/checkout/balances/Balances.java | 28 ++ .../com/checkout/balances/BalancesClient.java | 41 +++ .../checkout/balances/BalancesClientImpl.java | 23 ++ .../checkout/balances/BalancesResponse.java | 7 + .../balances/CurrencyAccountBalance.java | 15 + .../checkout/balances/TopUpBankDetails.java | 26 ++ .../balances/TopUpFundingDetails.java | 68 ++++ .../balances/TopUpInstructionsResponse.java | 42 +++ .../java/com/checkout/SandboxTestFixture.java | 1 + .../balances/BalancesClientImplTest.java | 49 +++ .../balances/BalancesSerializationTest.java | 347 ++++++++++++++++++ .../com/checkout/balances/BalancesTestIT.java | 75 ++++ 13 files changed, 723 insertions(+) create mode 100644 src/main/java/com/checkout/balances/TopUpBankDetails.java create mode 100644 src/main/java/com/checkout/balances/TopUpFundingDetails.java create mode 100644 src/main/java/com/checkout/balances/TopUpInstructionsResponse.java create mode 100644 src/test/java/com/checkout/balances/BalancesSerializationTest.java diff --git a/src/main/java/com/checkout/OAuthScope.java b/src/main/java/com/checkout/OAuthScope.java index 42251d6a0..9ce451620 100644 --- a/src/main/java/com/checkout/OAuthScope.java +++ b/src/main/java/com/checkout/OAuthScope.java @@ -5,6 +5,7 @@ public enum OAuthScope { ACCOUNTS("accounts"), BALANCES("balances"), BALANCES_VIEW("balances:view"), + BALANCES_TOP_UP_INSTRUCTIONS("balances:top-up-instructions"), CARD_MANAGEMENT("card-management"), DISPUTES("disputes"), DISPUTES_ACCEPT("disputes:accept"), diff --git a/src/main/java/com/checkout/balances/Balances.java b/src/main/java/com/checkout/balances/Balances.java index 4cfabfbb6..224468eec 100644 --- a/src/main/java/com/checkout/balances/Balances.java +++ b/src/main/java/com/checkout/balances/Balances.java @@ -2,17 +2,45 @@ import lombok.Data; +/** + * The balance values held by a currency account (sub-account). + */ @Data public final class Balances { + /** + * The total incoming funds that will be added to the Available balance once cleared. + * [Optional] + */ private Long pending; + /** + * The funds that are available for processing. + * [Optional] + */ private Long available; + /** + * The funds reserved from the Available balance for outgoing transactions that are yet to + * clear. + * [Optional] + */ private Long payable; + /** + * The funds held by Checkout.com to cover potential liabilities and risk events associated + * with your account. + * [Optional] + */ private Long collateral; + /** + * The funds held for processing Payouts and Issuing payments when the Available balance is + * insufficient. + * [Optional] + */ + private Long operational; + /** * A breakdown of the funds held in the {@code collateral} balance. * [Optional] diff --git a/src/main/java/com/checkout/balances/BalancesClient.java b/src/main/java/com/checkout/balances/BalancesClient.java index 6901ea7b6..f099c15a2 100644 --- a/src/main/java/com/checkout/balances/BalancesClient.java +++ b/src/main/java/com/checkout/balances/BalancesClient.java @@ -4,9 +4,50 @@ public interface BalancesClient { + /** + * Retrieves the balances for each sub-account belonging to an entity. + * + * @param entityId the ID of the entity + * @param balancesQuery the query filter + * @return a future with the balances response + */ CompletableFuture retrieveEntityBalances(String entityId, BalancesQuery balancesQuery); + /** + * Retrieves the bank details required to top up a sub-account, along with the payment + * reference that attributes an incoming payment to that sub-account. + * Note: The sub-account is referred to as currency account in the API. + * + * @param entityId the ID of the entity that owns the sub-account, or of an entity + * above it in your hierarchy. A platform can use its own entity ID + * to reach the sub-accounts of any entity beneath it + * @param currencyAccountId the ID of the sub-account to retrieve top-up instructions for + * @return a future with the top-up instructions response + */ + CompletableFuture retrieveTopUpInstructions(String entityId, String currencyAccountId); + // Synchronous methods + + /** + * Retrieves the balances for each sub-account belonging to an entity. + * + * @param entityId the ID of the entity + * @param balancesQuery the query filter + * @return the balances response + */ BalancesResponse retrieveEntityBalancesSync(String entityId, BalancesQuery balancesQuery); + /** + * Retrieves the bank details required to top up a sub-account, along with the payment + * reference that attributes an incoming payment to that sub-account. + * Note: The sub-account is referred to as currency account in the API. + * + * @param entityId the ID of the entity that owns the sub-account, or of an entity + * above it in your hierarchy. A platform can use its own entity ID + * to reach the sub-accounts of any entity beneath it + * @param currencyAccountId the ID of the sub-account to retrieve top-up instructions for + * @return the top-up instructions response + */ + TopUpInstructionsResponse retrieveTopUpInstructionsSync(String entityId, String currencyAccountId); + } diff --git a/src/main/java/com/checkout/balances/BalancesClientImpl.java b/src/main/java/com/checkout/balances/BalancesClientImpl.java index 9a6e23087..ff42a7db2 100644 --- a/src/main/java/com/checkout/balances/BalancesClientImpl.java +++ b/src/main/java/com/checkout/balances/BalancesClientImpl.java @@ -10,6 +10,9 @@ public class BalancesClientImpl extends AbstractClient implements BalancesClient { private static final String BALANCES_PATH = "balances"; + private static final String ENTITIES_PATH = "entities"; + private static final String CURRENCY_ACCOUNTS_PATH = "currency-accounts"; + private static final String TOP_UP_INSTRUCTIONS_PATH = "top-up-instructions"; public BalancesClientImpl(final ApiClient apiClient, final CheckoutConfiguration configuration) { @@ -22,6 +25,12 @@ public CompletableFuture retrieveEntityBalances(final String e return apiClient.queryAsync(buildPath(BALANCES_PATH, entityId), sdkAuthorization(), balancesQuery, BalancesResponse.class); } + @Override + public CompletableFuture retrieveTopUpInstructions(final String entityId, final String currencyAccountId) { + validateEntityIdAndCurrencyAccountId(entityId, currencyAccountId); + return apiClient.getAsync(topUpInstructionsPath(entityId, currencyAccountId), sdkAuthorization(), TopUpInstructionsResponse.class); + } + // Synchronous methods @Override public BalancesResponse retrieveEntityBalancesSync(final String entityId, final BalancesQuery balancesQuery) { @@ -29,8 +38,22 @@ public BalancesResponse retrieveEntityBalancesSync(final String entityId, final return apiClient.query(buildPath(BALANCES_PATH, entityId), sdkAuthorization(), balancesQuery, BalancesResponse.class); } + @Override + public TopUpInstructionsResponse retrieveTopUpInstructionsSync(final String entityId, final String currencyAccountId) { + validateEntityIdAndCurrencyAccountId(entityId, currencyAccountId); + return apiClient.get(topUpInstructionsPath(entityId, currencyAccountId), sdkAuthorization(), TopUpInstructionsResponse.class); + } + // Common methods protected void validateEntityIdAndBalancesQuery(final String entityId, final BalancesQuery balancesQuery) { com.checkout.common.CheckoutUtils.validateParams("entityId", entityId, "balancesQuery", balancesQuery); } + + private void validateEntityIdAndCurrencyAccountId(final String entityId, final String currencyAccountId) { + com.checkout.common.CheckoutUtils.validateParams("entityId", entityId, "currencyAccountId", currencyAccountId); + } + + private static String topUpInstructionsPath(final String entityId, final String currencyAccountId) { + return buildPath(ENTITIES_PATH, entityId, CURRENCY_ACCOUNTS_PATH, currencyAccountId, TOP_UP_INSTRUCTIONS_PATH); + } } diff --git a/src/main/java/com/checkout/balances/BalancesResponse.java b/src/main/java/com/checkout/balances/BalancesResponse.java index a6c2cd829..87e7305b8 100644 --- a/src/main/java/com/checkout/balances/BalancesResponse.java +++ b/src/main/java/com/checkout/balances/BalancesResponse.java @@ -6,10 +6,17 @@ import java.util.List; +/** + * The balances for each currency account (sub-account) belonging to an entity. + */ @Data @EqualsAndHashCode(callSuper = true) public final class BalancesResponse extends HttpMetadata { + /** + * The balances for each currency account that matched the query. + * [Optional] + */ List data; } diff --git a/src/main/java/com/checkout/balances/CurrencyAccountBalance.java b/src/main/java/com/checkout/balances/CurrencyAccountBalance.java index 61eccc7bb..ae163266e 100644 --- a/src/main/java/com/checkout/balances/CurrencyAccountBalance.java +++ b/src/main/java/com/checkout/balances/CurrencyAccountBalance.java @@ -5,6 +5,9 @@ import java.time.Instant; +/** + * The balances held by a single currency account (sub-account). + */ @Data public final class CurrencyAccountBalance { @@ -15,10 +18,22 @@ public final class CurrencyAccountBalance { */ private String currencyAccountId; + /** + * A descriptor for the currency account. + * [Optional] + */ private String descriptor; + /** + * The holding currency of the currency account (the three character ISO 4217 code). + * [Optional] + */ private Currency holdingCurrency; + /** + * The balance values for the currency account. + * [Optional] + */ private Balances balances; /** diff --git a/src/main/java/com/checkout/balances/TopUpBankDetails.java b/src/main/java/com/checkout/balances/TopUpBankDetails.java new file mode 100644 index 000000000..05366251b --- /dev/null +++ b/src/main/java/com/checkout/balances/TopUpBankDetails.java @@ -0,0 +1,26 @@ +package com.checkout.balances; + +import lombok.Data; + +/** + * The bank details for each available funding rail. + * Both {@code domestic} and {@code international} are optional, and their availability depends on + * the sub-account's holding currency, jurisdiction, and banking partner. Do not assume that both + * rails are always available. + */ +@Data +public final class TopUpBankDetails { + + /** + * The bank details for the domestic funding rail. + * [Optional] + */ + private TopUpFundingDetails domestic; + + /** + * The bank details for the international funding rail. + * [Optional] + */ + private TopUpFundingDetails international; + +} diff --git a/src/main/java/com/checkout/balances/TopUpFundingDetails.java b/src/main/java/com/checkout/balances/TopUpFundingDetails.java new file mode 100644 index 000000000..656b881e2 --- /dev/null +++ b/src/main/java/com/checkout/balances/TopUpFundingDetails.java @@ -0,0 +1,68 @@ +package com.checkout.balances; + +import lombok.Data; + +/** + * The bank details for a single funding rail. + * {@code beneficiaryAccountName} and {@code bankName} are the only fields always returned. The + * remaining fields vary by rail and the receiving bank's jurisdiction, and are omitted when they + * do not apply. + */ +@Data +public final class TopUpFundingDetails { + + /** + * The name of the account that receives the funds. + * [Required] + */ + private String beneficiaryAccountName; + + /** + * The address of the beneficiary, if the rail requires it. + * [Optional] + */ + private String beneficiaryAddress; + + /** + * The name of the bank that receives the funds. + * [Required] + */ + private String bankName; + + /** + * The address of the receiving bank, if the rail requires it. + * [Optional] + */ + private String bankAddress; + + /** + * The account number of the receiving account. + * [Optional] + */ + private String accountNumber; + + /** + * The sort code of the receiving bank. Returned for United Kingdom domestic transfers. + * [Optional] + */ + private String sortCode; + + /** + * The routing number of the receiving bank. Returned for United States domestic transfers. + * [Optional] + */ + private String routingNumber; + + /** + * The International Bank Account Number of the receiving account. + * [Optional] + */ + private String iban; + + /** + * The SWIFT or BIC code of the receiving bank. Returned for international transfers. + * [Optional] + */ + private String swiftCode; + +} diff --git a/src/main/java/com/checkout/balances/TopUpInstructionsResponse.java b/src/main/java/com/checkout/balances/TopUpInstructionsResponse.java new file mode 100644 index 000000000..aa7339be2 --- /dev/null +++ b/src/main/java/com/checkout/balances/TopUpInstructionsResponse.java @@ -0,0 +1,42 @@ +package com.checkout.balances; + +import com.checkout.HttpMetadata; +import com.checkout.common.Currency; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * The bank details and payment reference used to top up a sub-account. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public final class TopUpInstructionsResponse extends HttpMetadata { + + /** + * The unique identifier of the sub-account that the instructions apply to. + * [Required] + */ + private String currencyAccountId; + + /** + * The currency that funds must be sent in, as a three-letter ISO 4217 currency code. + * This is the sub-account's holding currency, returned as {@code holding_currency} by the + * Retrieve entity balances endpoint. + * [Required] + */ + private Currency currency; + + /** + * The reference that must be quoted on the payment. It is how an incoming payment is + * attributed to the sub-account. A payment sent without this reference may not be credited. + * [Required] + */ + private String paymentReference; + + /** + * The bank details for each available funding rail. + * [Required] + */ + private TopUpBankDetails bankDetails; + +} diff --git a/src/test/java/com/checkout/SandboxTestFixture.java b/src/test/java/com/checkout/SandboxTestFixture.java index 3b8bdfac0..6efe5a753 100644 --- a/src/test/java/com/checkout/SandboxTestFixture.java +++ b/src/test/java/com/checkout/SandboxTestFixture.java @@ -92,6 +92,7 @@ public SandboxTestFixture(final PlatformType platformType) { OAuthScope.ACCOUNTS, OAuthScope.SESSIONS_APP, OAuthScope.SESSIONS_BROWSER, OAuthScope.VAULT, OAuthScope.PAYOUTS_BANK_DETAILS, OAuthScope.DISPUTES, OAuthScope.TRANSFERS_CREATE, OAuthScope.TRANSFERS_VIEW, OAuthScope.BALANCES_VIEW, + OAuthScope.BALANCES_TOP_UP_INSTRUCTIONS, OAuthScope.VAULT_CARD_METADATA, OAuthScope.FINANCIAL_ACTIONS, OAuthScope.FORWARD, OAuthScope.FORWARD_SECRETS, OAuthScope.PAYMENTS_SEARCH) .environment(Environment.SANDBOX) diff --git a/src/test/java/com/checkout/balances/BalancesClientImplTest.java b/src/test/java/com/checkout/balances/BalancesClientImplTest.java index 6819a9008..90b5015ce 100644 --- a/src/test/java/com/checkout/balances/BalancesClientImplTest.java +++ b/src/test/java/com/checkout/balances/BalancesClientImplTest.java @@ -1,6 +1,7 @@ package com.checkout.balances; import com.checkout.ApiClient; +import com.checkout.CheckoutArgumentException; import com.checkout.CheckoutConfiguration; import com.checkout.SdkAuthorization; import com.checkout.SdkAuthorizationType; @@ -9,6 +10,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -18,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; @@ -27,6 +31,11 @@ @ExtendWith(MockitoExtension.class) class BalancesClientImplTest { + private static final String ENTITY_ID = "ent_w4jelhppmfiufdnatam37wrfc4"; + private static final String CURRENCY_ACCOUNT_ID = "ca_g5y7d6jo4e2urgforcbf2ey5jm"; + private static final String TOP_UP_INSTRUCTIONS_PATH = + "entities/" + ENTITY_ID + "/currency-accounts/" + CURRENCY_ACCOUNT_ID + "/top-up-instructions"; + @Mock private ApiClient apiClient; @@ -79,6 +88,46 @@ void shouldRetrieveEntityBalancesSync() { validateResponse(expectedResponse, actualResponse); } + @Test + void shouldRetrieveTopUpInstructions() throws ExecutionException, InterruptedException { + final TopUpInstructionsResponse expectedResponse = mock(TopUpInstructionsResponse.class); + + when(apiClient.getAsync(eq(TOP_UP_INSTRUCTIONS_PATH), any(SdkAuthorization.class), eq(TopUpInstructionsResponse.class))) + .thenReturn(CompletableFuture.completedFuture(expectedResponse)); + + final CompletableFuture future = + balancesClient.retrieveTopUpInstructions(ENTITY_ID, CURRENCY_ACCOUNT_ID); + + validateResponse(expectedResponse, future.get()); + } + + @Test + void shouldRetrieveTopUpInstructionsSync() { + final TopUpInstructionsResponse expectedResponse = mock(TopUpInstructionsResponse.class); + + when(apiClient.get(eq(TOP_UP_INSTRUCTIONS_PATH), any(SdkAuthorization.class), eq(TopUpInstructionsResponse.class))) + .thenReturn(expectedResponse); + + final TopUpInstructionsResponse actualResponse = + balancesClient.retrieveTopUpInstructionsSync(ENTITY_ID, CURRENCY_ACCOUNT_ID); + + validateResponse(expectedResponse, actualResponse); + } + + @ParameterizedTest + @CsvSource(value = { + "NULL, ca_g5y7d6jo4e2urgforcbf2ey5jm", + "'', ca_g5y7d6jo4e2urgforcbf2ey5jm", + "ent_w4jelhppmfiufdnatam37wrfc4, NULL", + "ent_w4jelhppmfiufdnatam37wrfc4, ''" + }, nullValues = "NULL") + void shouldFailWhenTopUpInstructionsIdentifiersAreMissing(final String entityId, final String currencyAccountId) { + assertThrows(CheckoutArgumentException.class, + () -> balancesClient.retrieveTopUpInstructions(entityId, currencyAccountId)); + assertThrows(CheckoutArgumentException.class, + () -> balancesClient.retrieveTopUpInstructionsSync(entityId, currencyAccountId)); + } + // Common methods private BalancesQuery createBalancesQuery() { return BalancesQuery.builder().build(); diff --git a/src/test/java/com/checkout/balances/BalancesSerializationTest.java b/src/test/java/com/checkout/balances/BalancesSerializationTest.java new file mode 100644 index 000000000..c9a63bd2d --- /dev/null +++ b/src/test/java/com/checkout/balances/BalancesSerializationTest.java @@ -0,0 +1,347 @@ +package com.checkout.balances; + +import com.checkout.GsonSerializer; +import com.checkout.common.Currency; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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; + +/** + * Schema validation tests for the balances package. + * + *

Covers the top-up instructions response + * (GET /entities/{entityId}/currency-accounts/{currencyAccountId}/top-up-instructions) and the + * entity balances response (GET /balances/{id}). Every value is taken from the field-level + * {@code example} values in shared/swagger-latest.json; neither response schema carries a + * top-level example. + * + *

The spec is explicit that neither funding rail is guaranteed: TopUpBankDetails declares no + * {@code required} array, so domestic-only, international-only and an empty bank_details are all + * legal 200 bodies. Each has its own test. + */ +class BalancesSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + private static final String BOTH_RAILS_JSON = "{" + + "\"currency_account_id\":\"ca_g5y7d6jo4e2urgforcbf2ey5jm\"," + + "\"currency\":\"USD\"," + + "\"payment_reference\":\"TP-ABC123\"," + + "\"bank_details\":{" + + "\"domestic\":" + fundingDetailsJson() + "," + + "\"international\":" + fundingDetailsJson() + + "}}"; + + private static String fundingDetailsJson() { + return "{" + + "\"beneficiary_account_name\":\"Acme Inc\"," + + "\"beneficiary_address\":\"1 Example Street, Exampleville, EX, 00000, US\"," + + "\"bank_name\":\"Example Bank\"," + + "\"bank_address\":\"1 Example Street, Exampleville, EX, 00000, US\"," + + "\"account_number\":\"1234567890\"," + + "\"sort_code\":\"000000\"," + + "\"routing_number\":\"000000000\"," + + "\"iban\":\"GB00EXAM00000000000000\"," + + "\"swift_code\":\"TESTUS00XXX\"" + + "}"; + } + + private static TopUpFundingDetails createFullyPopulatedFundingDetails() { + final TopUpFundingDetails details = new TopUpFundingDetails(); + details.setBeneficiaryAccountName("Acme Inc"); + details.setBeneficiaryAddress("1 Example Street, Exampleville, EX, 00000, US"); + details.setBankName("Example Bank"); + details.setBankAddress("1 Example Street, Exampleville, EX, 00000, US"); + details.setAccountNumber("1234567890"); + details.setSortCode("000000"); + details.setRoutingNumber("000000000"); + details.setIban("GB00EXAM00000000000000"); + details.setSwiftCode("TESTUS00XXX"); + return details; + } + + private static void assertFullyPopulatedFundingDetails(final TopUpFundingDetails details) { + assertNotNull(details); + assertEquals("Acme Inc", details.getBeneficiaryAccountName()); + assertEquals("1 Example Street, Exampleville, EX, 00000, US", details.getBeneficiaryAddress()); + assertEquals("Example Bank", details.getBankName()); + assertEquals("1 Example Street, Exampleville, EX, 00000, US", details.getBankAddress()); + assertEquals("1234567890", details.getAccountNumber()); + assertEquals("000000", details.getSortCode()); + assertEquals("000000000", details.getRoutingNumber()); + assertEquals("GB00EXAM00000000000000", details.getIban()); + assertEquals("TESTUS00XXX", details.getSwiftCode()); + } + + // ------------------------------------------------------------------------ + // TopUpInstructionsResponse / TopUpBankDetails / TopUpFundingDetails + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeTopUpInstructionsResponseWithRequiredFields() { + final TopUpInstructionsResponse response = new TopUpInstructionsResponse(); + response.setCurrencyAccountId("ca_g5y7d6jo4e2urgforcbf2ey5jm"); + response.setCurrency(Currency.USD); + response.setPaymentReference("TP-ABC123"); + response.setBankDetails(new TopUpBankDetails()); + + assertDoesNotThrow(() -> serializer.toJson(response)); + } + + @Test + void shouldSerializeTopUpInstructionsResponseToSnakeCase() { + final TopUpBankDetails bankDetails = new TopUpBankDetails(); + bankDetails.setDomestic(createFullyPopulatedFundingDetails()); + bankDetails.setInternational(createFullyPopulatedFundingDetails()); + + final TopUpInstructionsResponse response = new TopUpInstructionsResponse(); + response.setCurrencyAccountId("ca_g5y7d6jo4e2urgforcbf2ey5jm"); + response.setCurrency(Currency.USD); + response.setPaymentReference("TP-ABC123"); + response.setBankDetails(bankDetails); + + final String json = serializer.toJson(response); + + assertNotNull(json); + assertTrue(json.contains("\"currency_account_id\":\"ca_g5y7d6jo4e2urgforcbf2ey5jm\"")); + assertTrue(json.contains("\"currency\":\"USD\"")); + assertTrue(json.contains("\"payment_reference\":\"TP-ABC123\"")); + assertTrue(json.contains("\"bank_details\"")); + assertTrue(json.contains("\"domestic\"")); + assertTrue(json.contains("\"international\"")); + assertTrue(json.contains("\"beneficiary_account_name\":\"Acme Inc\"")); + assertTrue(json.contains("\"beneficiary_address\":\"1 Example Street, Exampleville, EX, 00000, US\"")); + assertTrue(json.contains("\"bank_name\":\"Example Bank\"")); + assertTrue(json.contains("\"bank_address\":\"1 Example Street, Exampleville, EX, 00000, US\"")); + assertTrue(json.contains("\"account_number\":\"1234567890\"")); + assertTrue(json.contains("\"sort_code\":\"000000\"")); + assertTrue(json.contains("\"routing_number\":\"000000000\"")); + assertTrue(json.contains("\"iban\":\"GB00EXAM00000000000000\"")); + assertTrue(json.contains("\"swift_code\":\"TESTUS00XXX\"")); + } + + @Test + void shouldDeserializeSpecExampleForTopUpInstructionsResponse() { + final TopUpInstructionsResponse response = + serializer.fromJson(BOTH_RAILS_JSON, TopUpInstructionsResponse.class); + + assertNotNull(response); + assertEquals("ca_g5y7d6jo4e2urgforcbf2ey5jm", response.getCurrencyAccountId()); + assertEquals(Currency.USD, response.getCurrency()); + assertEquals("TP-ABC123", response.getPaymentReference()); + assertNotNull(response.getBankDetails()); + assertFullyPopulatedFundingDetails(response.getBankDetails().getDomestic()); + assertFullyPopulatedFundingDetails(response.getBankDetails().getInternational()); + } + + @Test + void shouldRoundTripTopUpInstructionsResponse() { + final TopUpBankDetails bankDetails = new TopUpBankDetails(); + bankDetails.setDomestic(createFullyPopulatedFundingDetails()); + bankDetails.setInternational(createFullyPopulatedFundingDetails()); + + final TopUpInstructionsResponse original = new TopUpInstructionsResponse(); + original.setCurrencyAccountId("ca_g5y7d6jo4e2urgforcbf2ey5jm"); + original.setCurrency(Currency.USD); + original.setPaymentReference("TP-ABC123"); + original.setBankDetails(bankDetails); + + final TopUpInstructionsResponse deserialized = + serializer.fromJson(serializer.toJson(original), TopUpInstructionsResponse.class); + + assertEquals(original.getCurrencyAccountId(), deserialized.getCurrencyAccountId()); + assertEquals(original.getCurrency(), deserialized.getCurrency()); + assertEquals(original.getPaymentReference(), deserialized.getPaymentReference()); + assertFullyPopulatedFundingDetails(deserialized.getBankDetails().getDomestic()); + assertFullyPopulatedFundingDetails(deserialized.getBankDetails().getInternational()); + } + + @Test + void shouldDeserializeDomesticOnlyTopUpInstructionsResponse() { + // A United States domestic rail, per "Returned for United States domestic transfers" + // on routing_number. + final String json = "{" + + "\"currency_account_id\":\"ca_g5y7d6jo4e2urgforcbf2ey5jm\"," + + "\"currency\":\"USD\"," + + "\"payment_reference\":\"TP-ABC123\"," + + "\"bank_details\":{\"domestic\":{" + + "\"beneficiary_account_name\":\"Acme Inc\"," + + "\"bank_name\":\"Example Bank\"," + + "\"account_number\":\"1234567890\"," + + "\"routing_number\":\"000000000\"" + + "}}}"; + + final TopUpInstructionsResponse response = + serializer.fromJson(json, TopUpInstructionsResponse.class); + + assertNotNull(response.getBankDetails()); + assertNull(response.getBankDetails().getInternational()); + assertNotNull(response.getBankDetails().getDomestic()); + assertEquals("Acme Inc", response.getBankDetails().getDomestic().getBeneficiaryAccountName()); + assertEquals("Example Bank", response.getBankDetails().getDomestic().getBankName()); + assertEquals("1234567890", response.getBankDetails().getDomestic().getAccountNumber()); + assertEquals("000000000", response.getBankDetails().getDomestic().getRoutingNumber()); + assertNull(response.getBankDetails().getDomestic().getSortCode()); + assertNull(response.getBankDetails().getDomestic().getIban()); + assertNull(response.getBankDetails().getDomestic().getSwiftCode()); + } + + @Test + void shouldDeserializeInternationalOnlyTopUpInstructionsResponse() { + // An international rail, per "Returned for international transfers" on swift_code. + final String json = "{" + + "\"currency_account_id\":\"ca_g5y7d6jo4e2urgforcbf2ey5jm\"," + + "\"currency\":\"USD\"," + + "\"payment_reference\":\"TP-ABC123\"," + + "\"bank_details\":{\"international\":{" + + "\"beneficiary_account_name\":\"Acme Inc\"," + + "\"bank_name\":\"Example Bank\"," + + "\"iban\":\"GB00EXAM00000000000000\"," + + "\"swift_code\":\"TESTUS00XXX\"" + + "}}}"; + + final TopUpInstructionsResponse response = + serializer.fromJson(json, TopUpInstructionsResponse.class); + + assertNotNull(response.getBankDetails()); + assertNull(response.getBankDetails().getDomestic()); + assertNotNull(response.getBankDetails().getInternational()); + assertEquals("Acme Inc", response.getBankDetails().getInternational().getBeneficiaryAccountName()); + assertEquals("Example Bank", response.getBankDetails().getInternational().getBankName()); + assertEquals("GB00EXAM00000000000000", response.getBankDetails().getInternational().getIban()); + assertEquals("TESTUS00XXX", response.getBankDetails().getInternational().getSwiftCode()); + assertNull(response.getBankDetails().getInternational().getAccountNumber()); + assertNull(response.getBankDetails().getInternational().getRoutingNumber()); + assertNull(response.getBankDetails().getInternational().getSortCode()); + } + + @Test + void shouldDeserializeEmptyBankDetailsForTopUpInstructionsResponse() { + // TopUpBankDetails declares no required properties, so an empty object is legal. + final String json = "{" + + "\"currency_account_id\":\"ca_g5y7d6jo4e2urgforcbf2ey5jm\"," + + "\"currency\":\"USD\"," + + "\"payment_reference\":\"TP-ABC123\"," + + "\"bank_details\":{}" + + "}"; + + final TopUpInstructionsResponse response = + serializer.fromJson(json, TopUpInstructionsResponse.class); + + assertNotNull(response); + assertNotNull(response.getBankDetails()); + assertNull(response.getBankDetails().getDomestic()); + assertNull(response.getBankDetails().getInternational()); + } + + @Test + void shouldOmitUnsetOptionalFundingFields() { + final TopUpFundingDetails domestic = new TopUpFundingDetails(); + domestic.setBeneficiaryAccountName("Acme Inc"); + domestic.setBankName("Example Bank"); + + final TopUpBankDetails bankDetails = new TopUpBankDetails(); + bankDetails.setDomestic(domestic); + + final TopUpInstructionsResponse response = new TopUpInstructionsResponse(); + response.setCurrencyAccountId("ca_g5y7d6jo4e2urgforcbf2ey5jm"); + response.setCurrency(Currency.USD); + response.setPaymentReference("TP-ABC123"); + response.setBankDetails(bankDetails); + + final String json = serializer.toJson(response); + + assertTrue(json.contains("\"beneficiary_account_name\":\"Acme Inc\"")); + assertTrue(json.contains("\"bank_name\":\"Example Bank\"")); + assertFalse(json.contains("international")); + assertFalse(json.contains("sort_code")); + assertFalse(json.contains("routing_number")); + assertFalse(json.contains("iban")); + assertFalse(json.contains("swift_code")); + assertFalse(json.contains("beneficiary_address")); + assertFalse(json.contains("bank_address")); + assertFalse(json.contains("account_number")); + } + + // ------------------------------------------------------------------------ + // BalancesResponse / CurrencyAccountBalance / Balances / CollateralBreakdown + // + // Added when Balance.operational was found missing from the SDK during the INT-1692 review; + // review-integrity.mdc section 9 requires a serialization test for a new field on an + // existing class. + // ------------------------------------------------------------------------ + + @Test + void shouldDeserializeSpecExampleForBalancesResponse() { + final String json = "{\"data\":[{" + + "\"currency_account_id\":\"ca_g5y7d6jo4e2urgforcbf2ey5jm\"," + + "\"descriptor\":\"Revenue Account 1\"," + + "\"holding_currency\":\"USD\"," + + "\"balances_as_of\":\"2026-05-06T13:59:59Z\"," + + "\"balances\":{" + + "\"pending\":23000," + + "\"available\":50000," + + "\"payable\":2700," + + "\"collateral\":6000," + + "\"operational\":7000," + + "\"collateral_breakdown\":{\"fixed_reserve\":4000,\"rolling_reserve\":2000}" + + "}}]}"; + + final BalancesResponse response = serializer.fromJson(json, BalancesResponse.class); + + assertNotNull(response); + assertNotNull(response.getData()); + assertEquals(1, response.getData().size()); + + final CurrencyAccountBalance balance = response.getData().get(0); + assertEquals("ca_g5y7d6jo4e2urgforcbf2ey5jm", balance.getCurrencyAccountId()); + assertEquals("Revenue Account 1", balance.getDescriptor()); + assertEquals(Currency.USD, balance.getHoldingCurrency()); + assertEquals(Instant.parse("2026-05-06T13:59:59Z"), balance.getBalancesAsOf()); + + assertNotNull(balance.getBalances()); + assertEquals(Long.valueOf(23000L), balance.getBalances().getPending()); + assertEquals(Long.valueOf(50000L), balance.getBalances().getAvailable()); + assertEquals(Long.valueOf(2700L), balance.getBalances().getPayable()); + assertEquals(Long.valueOf(6000L), balance.getBalances().getCollateral()); + assertEquals(Long.valueOf(7000L), balance.getBalances().getOperational()); + assertNotNull(balance.getBalances().getCollateralBreakdown()); + assertEquals(Long.valueOf(4000L), balance.getBalances().getCollateralBreakdown().getFixedReserve()); + assertEquals(Long.valueOf(2000L), balance.getBalances().getCollateralBreakdown().getRollingReserve()); + } + + @Test + void shouldRoundTripBalances() { + final CollateralBreakdown breakdown = new CollateralBreakdown(); + breakdown.setFixedReserve(4000L); + breakdown.setRollingReserve(2000L); + + final Balances original = new Balances(); + original.setPending(23000L); + original.setAvailable(50000L); + original.setPayable(2700L); + original.setCollateral(6000L); + original.setOperational(7000L); + original.setCollateralBreakdown(breakdown); + + final String json = serializer.toJson(original); + final Balances deserialized = serializer.fromJson(json, Balances.class); + + assertTrue(json.contains("\"operational\":7000")); + assertTrue(json.contains("\"collateral_breakdown\"")); + assertEquals(original.getPending(), deserialized.getPending()); + assertEquals(original.getAvailable(), deserialized.getAvailable()); + assertEquals(original.getPayable(), deserialized.getPayable()); + assertEquals(original.getCollateral(), deserialized.getCollateral()); + assertEquals(original.getOperational(), deserialized.getOperational()); + assertEquals(breakdown.getFixedReserve(), deserialized.getCollateralBreakdown().getFixedReserve()); + assertEquals(breakdown.getRollingReserve(), deserialized.getCollateralBreakdown().getRollingReserve()); + } +} diff --git a/src/test/java/com/checkout/balances/BalancesTestIT.java b/src/test/java/com/checkout/balances/BalancesTestIT.java index 3561f4d0a..76918a43a 100644 --- a/src/test/java/com/checkout/balances/BalancesTestIT.java +++ b/src/test/java/com/checkout/balances/BalancesTestIT.java @@ -1,14 +1,21 @@ package com.checkout.balances; +import com.checkout.CheckoutApiException; import com.checkout.PlatformType; import com.checkout.SandboxTestFixture; import com.checkout.common.Currency; import org.junit.jupiter.api.Test; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class BalancesTestIT extends SandboxTestFixture { + private static final String ENTITY_ID = "ent_kidtcgc3ge5unf4a5i6enhnr5m"; + BalancesTestIT() { super(PlatformType.DEFAULT_OAUTH); } @@ -34,6 +41,74 @@ void shouldRetrieveEntityBalancesSync() { validateBalancesResponse(balancesResponse); } + + /** + * GET /entities/{entityId}/currency-accounts/{currencyAccountId}/top-up-instructions. + * + *

Top-ups are not enabled on the sandbox sub-accounts this suite has access to, so the + * endpoint answers 403 ("top-ups aren't enabled for the sub-account") rather than 200. + * Verified live on 2026-09-07 with the balances:top-up-instructions scope granted, which the + * sandbox IdP does issue. + * + *

The test accepts either outcome, but only the outcomes the spec documents as "not + * available here": 403 and 404. It still fails on 400 (malformed identifiers, i.e. the SDK + * built the path wrongly) and on 401 (wrong authorization type), which are the two ways this + * endpoint could actually be broken in the SDK. + * + *

Uses the synchronous method deliberately: the {@code blocking(...)} helper retries any + * throwable 10 times before failing, which would turn a deterministic 403 into 10 wasted + * calls and an unhelpful assertion error. + */ + @Test + void shouldRetrieveTopUpInstructions() { + final BalancesResponse balances = checkoutApi.balancesClient() + .retrieveEntityBalancesSync(ENTITY_ID, BalancesQuery.builder().withCurrencyAccountId(true).build()); + + assertNotNull(balances); + assertNotNull(balances.getData()); + + // Take the first sub-account that reports an id. Requiring the entity to always have one + // would fail this test for a reason unrelated to top-up instructions. + final String currencyAccountId = balances.getData().stream() + .map(CurrencyAccountBalance::getCurrencyAccountId) + .filter(id -> id != null && !id.trim().isEmpty()) + .findFirst() + .orElse(null); + if (currencyAccountId == null) { + return; + } + + try { + final TopUpInstructionsResponse instructions = checkoutApi.balancesClient() + .retrieveTopUpInstructionsSync(ENTITY_ID, currencyAccountId); + + assertNotNull(instructions); + assertEquals(currencyAccountId, instructions.getCurrencyAccountId()); + assertNotNull(instructions.getCurrency()); + assertNotNull(instructions.getPaymentReference()); + assertNotNull(instructions.getBankDetails()); + + // Assert only what the spec guarantees. TopUpBankDetails declares no required + // properties, so an empty bank_details is a legal 200 body -- do not require a rail + // to be present. Where a rail IS returned, its two required fields must be. + for (final TopUpFundingDetails rail : Arrays.asList( + instructions.getBankDetails().getDomestic(), + instructions.getBankDetails().getInternational())) { + if (rail == null) { + continue; + } + assertNotNull(rail.getBeneficiaryAccountName()); + assertNotNull(rail.getBankName()); + } + } catch (final CheckoutApiException e) { + // 403 = top-ups not enabled for the sub-account, or the credential lacks access. + // 404 = sub-account not found, or it has no top-up instructions available. + // Anything else means the SDK, not the environment, is at fault. + assertTrue(Arrays.asList(403, 404).contains(e.getHttpStatusCode()), + "unexpected status " + e.getHttpStatusCode() + " from top-up instructions"); + } + } + // Common methods private BalancesQuery createBalancesQuery() { return BalancesQuery.builder() From 67f0e7b3594d61d995fe3dda4692cb9fedda9607 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Wed, 9 Sep 2026 12:03:56 +0200 Subject: [PATCH 2/2] New oauth test --- .../java/com/checkout/OAuthScopeTest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/test/java/com/checkout/OAuthScopeTest.java diff --git a/src/test/java/com/checkout/OAuthScopeTest.java b/src/test/java/com/checkout/OAuthScopeTest.java new file mode 100644 index 000000000..90460267d --- /dev/null +++ b/src/test/java/com/checkout/OAuthScopeTest.java @@ -0,0 +1,26 @@ +package com.checkout; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class OAuthScopeTest { + + /** + * The enum constant's String is the only place a scope's wire value is written down, and + * OAuthSdkCredentials builds the token request from getScope() rather than from the constant + * name. A typo is therefore invisible at compile time and surfaces only at the token endpoint, + * which rejects the whole request when one requested scope is undefined -- so a caller would + * lose every scope it asked for alongside the bad one. + * + * Values come from components.securitySchemes.OAuth.flows.clientCredentials.scopes in + * shared/swagger-latest.json. + */ + @Test + void shouldExposeDocumentedBalancesScopeValues() { + assertEquals("balances", OAuthScope.BALANCES.getScope()); + assertEquals("balances:view", OAuthScope.BALANCES_VIEW.getScope()); + assertEquals("balances:top-up-instructions", OAuthScope.BALANCES_TOP_UP_INSTRUCTIONS.getScope()); + } + +}