From 31c9b0e0799ac7bddde2f7e48fa7dcdf65fa55e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:03:22 +0200 Subject: [PATCH 1/2] feat(accounts): add the ISV payout schedule fields and funds_transfer_type on payouts From the 2026-08-05 spec. Verified against it with the RAG rather than the ticket text, which turned out to overstate the work. The ISV part is not five new schemas. The payout schedule endpoint gained an anyOf with a SaaS seller variant, and what that variant actually adds on the wire is two fields: balance_minimum, the amount to retain in the sub-entity's available balance, and carry_forward_enabled, whether a balance below the minimum rolls into the next payout. Both go on the request and the response. The ScheduleFrequency*Isv schemas serialise identically to the ones already here: same by_day array, same by_month_day array. Only the accepted values differ, and the API enforces them. Adding parallel classes with identical wire shapes would have doubled the public surface for nothing, so the constraints are documented on the existing classes instead: working days only for ISV weekly and daily, and only [1], [15], [1,15] or [1,16] for ISV monthly. The card payout side is one field, and the ticket points at the wrong half: funds_transfer_type already existed on the payout REQUEST via PaymentInstruction. What was missing is the response, where PaymentInstructionResponse carried only value_date. Two of the four tests cover absence rather than presence. A standard schedule must not emit balance_minimum or carry_forward_enabled at all, since either leaking in as a null or a zero would make the API read it as an ISV schedule, and both must come back null rather than 0/false so a caller can tell "not applicable" from "set to zero". Blocks C and D of the ticket need no work in Java: the hosted payment and payment link responses extend Resource, which already maps _links, and PaymentInstrumentRequest already has label, currency and instrumentDetails. Refs INT-1684. --- .../ScheduleFrequencyDailyRequest.java | 7 ++ .../ScheduleFrequencyMonthlyRequest.java | 7 ++ .../ScheduleFrequencyWeeklyRequest.java | 7 ++ .../request/UpdateScheduleRequest.java | 20 ++++ .../schedule/response/CurrencySchedule.java | 17 ++++ .../response/PaymentInstructionResponse.java | 9 ++ .../PayoutScheduleIsvSerializationTest.java | 95 +++++++++++++++++++ .../PayoutInstructionSerializationTest.java | 47 +++++++++ 8 files changed, 209 insertions(+) create mode 100644 src/test/java/com/checkout/accounts/PayoutScheduleIsvSerializationTest.java create mode 100644 src/test/java/com/checkout/payments/PayoutInstructionSerializationTest.java diff --git a/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyDailyRequest.java b/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyDailyRequest.java index a285e1f20..4be3d269d 100644 --- a/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyDailyRequest.java +++ b/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyDailyRequest.java @@ -6,6 +6,13 @@ @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) +/** + * A daily payout schedule. + * + *

For SaaS seller (ISV) sub-entities this runs on working days only, Monday to Friday, with no + * payout at weekends, and is based on the available balance as of 00:00 in the sub-entity's time + * zone. Standard sub-entities are paid out every day. + */ public final class ScheduleFrequencyDailyRequest extends ScheduleRequest { public ScheduleFrequencyDailyRequest() { diff --git a/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyMonthlyRequest.java b/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyMonthlyRequest.java index f94023c10..44e84c2fa 100644 --- a/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyMonthlyRequest.java +++ b/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyMonthlyRequest.java @@ -14,6 +14,13 @@ @Setter @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) +/** + * A monthly payout schedule. + * + *

SaaS seller (ISV) sub-entities accept only these combinations, in any order: {@code [1]}, + * {@code [15]}, {@code [1, 15]} or {@code [1, 16]}. Their payout is based on the available balance + * as of 00:00 in the sub-entity's time zone. Standard sub-entities accept any day from 1 to 28. + */ public final class ScheduleFrequencyMonthlyRequest extends ScheduleRequest { @SerializedName("by_month_day") diff --git a/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyWeeklyRequest.java b/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyWeeklyRequest.java index 113e1cdd5..dbdfa0565 100644 --- a/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyWeeklyRequest.java +++ b/src/main/java/com/checkout/accounts/payout/schedule/request/ScheduleFrequencyWeeklyRequest.java @@ -15,6 +15,13 @@ @Setter @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) +/** + * A weekly payout schedule. + * + *

SaaS seller (ISV) sub-entities accept working days only, Monday to Friday: a schedule set to + * a Saturday or Sunday is rejected. Their payout is based on the available balance as of 00:00 in + * the sub-entity's time zone. Standard sub-entities accept any day. + */ public final class ScheduleFrequencyWeeklyRequest extends ScheduleRequest { @SerializedName("by_day") diff --git a/src/main/java/com/checkout/accounts/payout/schedule/request/UpdateScheduleRequest.java b/src/main/java/com/checkout/accounts/payout/schedule/request/UpdateScheduleRequest.java index 0d8ca5c72..053228dd0 100644 --- a/src/main/java/com/checkout/accounts/payout/schedule/request/UpdateScheduleRequest.java +++ b/src/main/java/com/checkout/accounts/payout/schedule/request/UpdateScheduleRequest.java @@ -1,5 +1,6 @@ package com.checkout.accounts.payout.schedule.request; +import com.google.gson.annotations.SerializedName; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -15,6 +16,25 @@ public final class UpdateScheduleRequest { private int threshold; + /** + * The amount, in the minor units of the schedule's currency, to retain in the sub-entity's + * available balance. Checkout.com pays out only the funds above this, and generates no payout + * if there are none. Defaults to {@code 0} when not set. + * + *

SaaS seller (ISV) schedules only. + */ + @SerializedName("balance_minimum") + private Long balanceMinimum; + + /** + * Whether to carry forward to the next payout any balance below the configured minimum. + * Defaults to {@code false} when not set. + * + *

SaaS seller (ISV) schedules only. + */ + @SerializedName("carry_forward_enabled") + private Boolean carryForwardEnabled; + private ScheduleRequest recurrence; } diff --git a/src/main/java/com/checkout/accounts/payout/schedule/response/CurrencySchedule.java b/src/main/java/com/checkout/accounts/payout/schedule/response/CurrencySchedule.java index 69ca3a5c0..44dcb73d9 100644 --- a/src/main/java/com/checkout/accounts/payout/schedule/response/CurrencySchedule.java +++ b/src/main/java/com/checkout/accounts/payout/schedule/response/CurrencySchedule.java @@ -1,5 +1,6 @@ package com.checkout.accounts.payout.schedule.response; +import com.google.gson.annotations.SerializedName; import lombok.Data; @Data @@ -9,6 +10,22 @@ public final class CurrencySchedule { private Integer threshold; + /** + * The amount, in the minor units of the schedule's currency, retained in the sub-entity's + * available balance. Only the funds above this are paid out. + * + *

Returned for SaaS seller (ISV) schedules. + */ + @SerializedName("balance_minimum") + private Long balanceMinimum; + + /** + * Whether a balance below the configured minimum is carried forward to the next payout. + * Always returned for SaaS sellers, where it defaults to {@code false}. + */ + @SerializedName("carry_forward_enabled") + private Boolean carryForwardEnabled; + private ScheduleResponse recurrence; } diff --git a/src/main/java/com/checkout/payments/response/PaymentInstructionResponse.java b/src/main/java/com/checkout/payments/response/PaymentInstructionResponse.java index ef34909f6..bb52e7889 100644 --- a/src/main/java/com/checkout/payments/response/PaymentInstructionResponse.java +++ b/src/main/java/com/checkout/payments/response/PaymentInstructionResponse.java @@ -1,16 +1,25 @@ package com.checkout.payments.response; +import com.google.gson.annotations.SerializedName; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.time.Instant; @Data @Builder @AllArgsConstructor +@NoArgsConstructor public final class PaymentInstructionResponse { private Instant valueDate; + /** + * The scheme's categorisation of the client, for example {@code FD}, {@code MT} or {@code AA}. + */ + @SerializedName("funds_transfer_type") + private String fundsTransferType; + } \ No newline at end of file diff --git a/src/test/java/com/checkout/accounts/PayoutScheduleIsvSerializationTest.java b/src/test/java/com/checkout/accounts/PayoutScheduleIsvSerializationTest.java new file mode 100644 index 000000000..417f88c44 --- /dev/null +++ b/src/test/java/com/checkout/accounts/PayoutScheduleIsvSerializationTest.java @@ -0,0 +1,95 @@ +package com.checkout.accounts; + +import com.checkout.GsonSerializer; +import com.checkout.accounts.payout.schedule.DaySchedule; +import com.checkout.accounts.payout.schedule.request.ScheduleFrequencyMonthlyRequest; +import com.checkout.accounts.payout.schedule.request.ScheduleFrequencyWeeklyRequest; +import com.checkout.accounts.payout.schedule.request.UpdateScheduleRequest; +import com.checkout.accounts.payout.schedule.response.CurrencySchedule; +import org.junit.jupiter.api.Test; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the SaaS seller (ISV) payout schedule fields added by the 2026-08-05 spec. + */ +class PayoutScheduleIsvSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + @Test + void shouldSerializeIsvScheduleFields() { + final UpdateScheduleRequest request = UpdateScheduleRequest.builder() + .enabled(true) + .threshold(100) + .balanceMinimum(500L) + .carryForwardEnabled(true) + .recurrence(ScheduleFrequencyWeeklyRequest.builder() + .byDays(Collections.singletonList(DaySchedule.MONDAY)) + .build()) + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"balance_minimum\":500"), json); + assertTrue(json.contains("\"carry_forward_enabled\":true"), json); + assertTrue(json.contains("\"by_day\":[\"monday\"]"), json); + } + + /** + * A standard sub-entity has no balance minimum and no carry-forward, so neither may appear in + * its request body. If they leaked in as nulls or zeros the API would read a standard schedule + * as an ISV one. + */ + @Test + void shouldOmitIsvFieldsWhenUnset() { + final UpdateScheduleRequest request = UpdateScheduleRequest.builder() + .enabled(true) + .threshold(100) + .recurrence(ScheduleFrequencyMonthlyRequest.builder() + .byMonthDays(Arrays.asList(1, 15)) + .build()) + .build(); + + final String json = serializer.toJson(request); + + assertFalse(json.contains("balance_minimum"), json); + assertFalse(json.contains("carry_forward_enabled"), json); + assertTrue(json.contains("\"by_month_day\":[1,15]"), json); + } + + @Test + void shouldDeserializeIsvScheduleFields() { + final String json = "{\"enabled\":true,\"threshold\":100,\"balance_minimum\":500," + + "\"carry_forward_enabled\":true," + + "\"recurrence\":{\"frequency\":\"Weekly\",\"by_day\":[\"monday\"]}}"; + + final CurrencySchedule schedule = serializer.fromJson(json, CurrencySchedule.class); + + assertTrue(schedule.getEnabled()); + assertEquals(100, schedule.getThreshold()); + assertEquals(500L, schedule.getBalanceMinimum()); + assertTrue(schedule.getCarryForwardEnabled()); + } + + /** + * A standard schedule omits both fields, and they must come back null rather than 0/false: + * a caller cannot otherwise tell "not applicable" from "set to zero". + */ + @Test + void shouldLeaveIsvFieldsNullForAStandardSchedule() { + final String json = "{\"enabled\":true,\"threshold\":100," + + "\"recurrence\":{\"frequency\":\"Daily\"}}"; + + final CurrencySchedule schedule = serializer.fromJson(json, CurrencySchedule.class); + + assertNull(schedule.getBalanceMinimum()); + assertNull(schedule.getCarryForwardEnabled()); + } +} diff --git a/src/test/java/com/checkout/payments/PayoutInstructionSerializationTest.java b/src/test/java/com/checkout/payments/PayoutInstructionSerializationTest.java new file mode 100644 index 000000000..13ba50f9f --- /dev/null +++ b/src/test/java/com/checkout/payments/PayoutInstructionSerializationTest.java @@ -0,0 +1,47 @@ +package com.checkout.payments; + +import com.checkout.GsonSerializer; +import com.checkout.payments.response.PayoutResponse; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Covers {@code instruction.funds_transfer_type} on the card payout response, added by the + * 2026-08-05 spec. The field was already modelled on the payout *request*; this is the response + * side, which had only {@code value_date}. + */ +class PayoutInstructionSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + @Test + void shouldDeserializeFundsTransferTypeOnThePayoutInstruction() { + final String json = "{\"id\":\"pay_1\",\"status\":\"Accepted\",\"reference\":\"ORD-1\"," + + "\"instruction\":{\"value_date\":\"2026-08-05T10:00:00Z\"," + + "\"funds_transfer_type\":\"AA\"}}"; + + final PayoutResponse response = serializer.fromJson(json, PayoutResponse.class); + + assertNotNull(response.getInstruction()); + assertEquals("AA", response.getInstruction().getFundsTransferType()); + assertNotNull(response.getInstruction().getValueDate()); + } + + /** + * The scheme does not always categorise the client, so the field has to survive being absent + * rather than defaulting to something that reads as a real categorisation. + */ + @Test + void shouldLeaveFundsTransferTypeNullWhenAbsent() { + final String json = "{\"id\":\"pay_1\",\"status\":\"Accepted\"," + + "\"instruction\":{\"value_date\":\"2026-08-05T10:00:00Z\"}}"; + + final PayoutResponse response = serializer.fromJson(json, PayoutResponse.class); + + assertNotNull(response.getInstruction()); + assertNull(response.getInstruction().getFundsTransferType()); + } +} From 14107c1b7b1d4367fce28d93be15749af46d0ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:06:57 +0200 Subject: [PATCH 2/2] feat(accounts): add payment_instrument_id to the payout schedule The field that says where a sub-entity's payout actually goes was missing from both the request and the response, in all seven SDKs, and it never appeared in the swagger changelog, so it predates the point where changes started being tracked. Found while implementing the ISV fields in the same two classes. Riding along in INT-1684 rather than getting its own row: it is one property, and tracking a ticket per property costs more than it returns. Called out here and in the PR so it is not invisible. The doc comment carries the bit that is easy to get wrong: for SaaS sellers it is optional, but when supplied it must reference a verified payment instrument or the API rejects the request. The existing four tests were extended rather than new ones added, including the two that assert absence, since a payout destination leaking into a standard schedule request is the same class of bug as the ISV fields leaking in. Refs INT-1684. --- .../payout/schedule/request/UpdateScheduleRequest.java | 9 +++++++++ .../payout/schedule/response/CurrencySchedule.java | 6 ++++++ .../accounts/PayoutScheduleIsvSerializationTest.java | 6 ++++++ 3 files changed, 21 insertions(+) diff --git a/src/main/java/com/checkout/accounts/payout/schedule/request/UpdateScheduleRequest.java b/src/main/java/com/checkout/accounts/payout/schedule/request/UpdateScheduleRequest.java index 053228dd0..fec4cd2ec 100644 --- a/src/main/java/com/checkout/accounts/payout/schedule/request/UpdateScheduleRequest.java +++ b/src/main/java/com/checkout/accounts/payout/schedule/request/UpdateScheduleRequest.java @@ -16,6 +16,15 @@ public final class UpdateScheduleRequest { private int threshold; + /** + * The ID of the platforms payment instrument to pay out to on this schedule. + * + *

Optional for SaaS seller (ISV) schedules, but when supplied it must reference a + * verified payment instrument, otherwise the request is rejected. + */ + @SerializedName("payment_instrument_id") + private String paymentInstrumentId; + /** * The amount, in the minor units of the schedule's currency, to retain in the sub-entity's * available balance. Checkout.com pays out only the funds above this, and generates no payout diff --git a/src/main/java/com/checkout/accounts/payout/schedule/response/CurrencySchedule.java b/src/main/java/com/checkout/accounts/payout/schedule/response/CurrencySchedule.java index 44dcb73d9..e991c9bcd 100644 --- a/src/main/java/com/checkout/accounts/payout/schedule/response/CurrencySchedule.java +++ b/src/main/java/com/checkout/accounts/payout/schedule/response/CurrencySchedule.java @@ -10,6 +10,12 @@ public final class CurrencySchedule { private Integer threshold; + /** + * The ID of the platforms payment instrument this schedule pays out to. + */ + @SerializedName("payment_instrument_id") + private String paymentInstrumentId; + /** * The amount, in the minor units of the schedule's currency, retained in the sub-entity's * available balance. Only the funds above this are paid out. diff --git a/src/test/java/com/checkout/accounts/PayoutScheduleIsvSerializationTest.java b/src/test/java/com/checkout/accounts/PayoutScheduleIsvSerializationTest.java index 417f88c44..47a744bdb 100644 --- a/src/test/java/com/checkout/accounts/PayoutScheduleIsvSerializationTest.java +++ b/src/test/java/com/checkout/accounts/PayoutScheduleIsvSerializationTest.java @@ -30,6 +30,7 @@ void shouldSerializeIsvScheduleFields() { .threshold(100) .balanceMinimum(500L) .carryForwardEnabled(true) + .paymentInstrumentId("ppi_w4jelhppmfiufdnatam37wrfc4") .recurrence(ScheduleFrequencyWeeklyRequest.builder() .byDays(Collections.singletonList(DaySchedule.MONDAY)) .build()) @@ -39,6 +40,7 @@ void shouldSerializeIsvScheduleFields() { assertTrue(json.contains("\"balance_minimum\":500"), json); assertTrue(json.contains("\"carry_forward_enabled\":true"), json); + assertTrue(json.contains("\"payment_instrument_id\":\"ppi_w4jelhppmfiufdnatam37wrfc4\""), json); assertTrue(json.contains("\"by_day\":[\"monday\"]"), json); } @@ -61,6 +63,7 @@ void shouldOmitIsvFieldsWhenUnset() { assertFalse(json.contains("balance_minimum"), json); assertFalse(json.contains("carry_forward_enabled"), json); + assertFalse(json.contains("payment_instrument_id"), json); assertTrue(json.contains("\"by_month_day\":[1,15]"), json); } @@ -68,6 +71,7 @@ void shouldOmitIsvFieldsWhenUnset() { void shouldDeserializeIsvScheduleFields() { final String json = "{\"enabled\":true,\"threshold\":100,\"balance_minimum\":500," + "\"carry_forward_enabled\":true," + + "\"payment_instrument_id\":\"ppi_w4jelhppmfiufdnatam37wrfc4\"," + "\"recurrence\":{\"frequency\":\"Weekly\",\"by_day\":[\"monday\"]}}"; final CurrencySchedule schedule = serializer.fromJson(json, CurrencySchedule.class); @@ -76,6 +80,7 @@ void shouldDeserializeIsvScheduleFields() { assertEquals(100, schedule.getThreshold()); assertEquals(500L, schedule.getBalanceMinimum()); assertTrue(schedule.getCarryForwardEnabled()); + assertEquals("ppi_w4jelhppmfiufdnatam37wrfc4", schedule.getPaymentInstrumentId()); } /** @@ -91,5 +96,6 @@ void shouldLeaveIsvFieldsNullForAStandardSchedule() { assertNull(schedule.getBalanceMinimum()); assertNull(schedule.getCarryForwardEnabled()); + assertNull(schedule.getPaymentInstrumentId()); } }