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..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 @@ -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,34 @@ 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 + * 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..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 @@ -1,5 +1,6 @@ package com.checkout.accounts.payout.schedule.response; +import com.google.gson.annotations.SerializedName; import lombok.Data; @Data @@ -9,6 +10,28 @@ 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. + * + *
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..47a744bdb --- /dev/null +++ b/src/test/java/com/checkout/accounts/PayoutScheduleIsvSerializationTest.java @@ -0,0 +1,101 @@ +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) + .paymentInstrumentId("ppi_w4jelhppmfiufdnatam37wrfc4") + .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("\"payment_instrument_id\":\"ppi_w4jelhppmfiufdnatam37wrfc4\""), 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); + assertFalse(json.contains("payment_instrument_id"), 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," + + "\"payment_instrument_id\":\"ppi_w4jelhppmfiufdnatam37wrfc4\"," + + "\"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()); + assertEquals("ppi_w4jelhppmfiufdnatam37wrfc4", schedule.getPaymentInstrumentId()); + } + + /** + * 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()); + assertNull(schedule.getPaymentInstrumentId()); + } +} 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()); + } +}