From 9430bce58e978233e198302644e4c367b9973a7c Mon Sep 17 00:00:00 2001 From: david ruiz Date: Fri, 25 Sep 2026 10:49:28 +0200 Subject: [PATCH 1/4] Airline and accommodation sub-tree model alignment --- .../java/com/checkout/GsonSerializer.java | 47 ++++ .../setups/entities/industry/Industry.java | 12 +- .../checkout/payments/AccommodationData.java | 11 +- .../checkout/payments/AccommodationGuest.java | 3 + .../checkout/payments/AccommodationRoom.java | 3 + .../com/checkout/payments/AirlineData.java | 20 ++ .../checkout/payments/FlightLegDetails.java | 71 ++++- .../java/com/checkout/payments/Ticket.java | 37 ++- .../PaymentContextsAccommodationData.java | 19 +- .../PaymentContextsAccommodationRoom.java | 17 +- .../contexts/PaymentContextsAirlineData.java | 18 ++ .../PaymentContextsFlightLegDetails.java | 3 + .../contexts/PaymentContextsGuests.java | 3 + .../contexts/PaymentContextsPassenger.java | 9 +- .../contexts/PaymentContextsTicket.java | 3 + .../java/com/checkout/GsonSerializerTest.java | 55 ++++ ...aymentSetupsIndustrySerializationTest.java | 71 ++++- .../AccommodationDataSerializationTest.java | 15 +- .../AirlineDataSerializationTest.java | 263 ++++++++++++++++++ ...ymentContextsAirlineSerializationTest.java | 181 ++++++++++++ .../ProcessingDataDeserializationTest.java | 134 +++++++++ .../CustomDeserializerPolicyTest.java | 71 +++++ 22 files changed, 1037 insertions(+), 29 deletions(-) create mode 100644 src/test/java/com/checkout/payments/AirlineDataSerializationTest.java create mode 100644 src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java diff --git a/src/main/java/com/checkout/GsonSerializer.java b/src/main/java/com/checkout/GsonSerializer.java index 0bea1e31..6fca0252 100644 --- a/src/main/java/com/checkout/GsonSerializer.java +++ b/src/main/java/com/checkout/GsonSerializer.java @@ -108,6 +108,16 @@ public final class GsonSerializer implements Serializer { .registerTypeAdapter(LocalDate.class, (JsonSerializer) (LocalDate date, Type typeOfSrc, JsonSerializationContext context) -> new JsonPrimitive(date.format(DateTimeFormatter.ISO_LOCAL_DATE))) .registerTypeAdapter(LocalDate.class, getLocalDateJsonDeserializer()) + // processing.airline_data[].passenger is oneOf[array, object]: PayPal returns a + // single object where the array shape is declared. Read both, always write an array. + .registerTypeAdapter( + new TypeToken>() { + }.getType(), + singleOrArrayDeserializer(com.checkout.payments.Passenger.class)) + .registerTypeAdapter( + new TypeToken>() { + }.getType(), + singleOrArrayDeserializer(com.checkout.payments.contexts.PaymentContextsPassenger.class)) // Payments - AbstractSource (polymorphic deserialization) .registerTypeAdapterFactory( RuntimeTypeAdapterFactory.of( @@ -454,6 +464,43 @@ private static JsonDeserializer getInstantJsonDeserializer() { }; } + /** + * Reads a property the specification declares as {@code oneOf[array, object]} into a list, + * accepting either shape on the wire and normalizing a bare object into a single-element list. + *

+ * The first property to need this is {@code processing.airline_data[].passenger}. + * {@code AirlineData} declares it as an array, while + * {@code PaymentInterfacesProcessingAirlineData} declares it as {@code oneOf[array, object]} + * with the note "PayPal requires a single object". Both branches resolve to the same object, + * so normalizing to a list loses nothing. + *

+ * Only a deserializer is registered, never a serializer, so writing still goes through Gson's + * reflective adapter and always emits an array. That is the only valid outbound shape for + * {@code AirlineData}. Element deserialization is delegated to the supplied context, so the + * global {@code LOWER_CASE_WITH_UNDERSCORES} naming policy and the {@code LocalDate} adapter + * still apply; this deserializer never maps property names itself. + * + * @param elementType the list element type + * @param the list element type + * @return a deserializer that accepts a single object or an array + */ + private static JsonDeserializer> singleOrArrayDeserializer(final Class elementType) { + return (json, typeOfT, context) -> { + if (json == null || json.isJsonNull()) { + return null; + } + final List values = new ArrayList<>(); + if (json.isJsonArray()) { + for (final JsonElement element : json.getAsJsonArray()) { + values.add(context.deserialize(element, elementType)); + } + } else { + values.add(context.deserialize(json, elementType)); + } + return values; + }; + } + private static JsonDeserializer getLocalDateJsonDeserializer() { return (json, typeOfT, context) -> { String dateString = json.getAsString(); diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/industry/Industry.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/industry/Industry.java index 2e10c8c6..5e6db1c4 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/industry/Industry.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/industry/Industry.java @@ -18,13 +18,19 @@ public final class Industry { /** - * Airline industry-specific data for flight bookings and related payments + * Airline industry-specific data for flight bookings and related payments. + * [Optional] + *

+ * The specification declares {@code industry.airline} as an array. This was previously a + * single object, so it serialized as the object {@code airline}, a shape the API does not + * accept, meaning the value never reached the gateway. */ @SerializedName("airline") - private AirlineData airlineData; + private List airlineData; /** - * Accommodation industry-specific data for hotel and cruise bookings and related payments + * Accommodation industry-specific data for hotel and cruise bookings and related payments. + * [Optional] */ @SerializedName("accommodation") private List accommodationData; diff --git a/src/main/java/com/checkout/payments/AccommodationData.java b/src/main/java/com/checkout/payments/AccommodationData.java index d540a746..05127eb8 100644 --- a/src/main/java/com/checkout/payments/AccommodationData.java +++ b/src/main/java/com/checkout/payments/AccommodationData.java @@ -1,7 +1,6 @@ package com.checkout.payments; import com.checkout.common.Address; -import com.checkout.common.CountryCode; import com.checkout.common.Phone; import lombok.AllArgsConstructor; import lombok.Builder; @@ -11,6 +10,9 @@ import java.time.LocalDate; import java.util.List; +/** + * Contains information about the accommodation booked by the customer. + */ @Data @Builder @NoArgsConstructor @@ -56,10 +58,13 @@ public final class AccommodationData { private String state; /** - * The country where the property is located, as an ISO 3166-1 alpha-2 code. + * The ISO country code of the address. * [Optional] + *

+ * A free-form string rather than an ISO 3166-1 alpha-2 enum: the specification's example is + * the three-letter code {@code USA}, which no alpha-2 enum can represent. Mapping as string. */ - private CountryCode country; + private String country; /** * The city where the property is located. diff --git a/src/main/java/com/checkout/payments/AccommodationGuest.java b/src/main/java/com/checkout/payments/AccommodationGuest.java index 3fe37bd1..f0a4d91d 100644 --- a/src/main/java/com/checkout/payments/AccommodationGuest.java +++ b/src/main/java/com/checkout/payments/AccommodationGuest.java @@ -7,6 +7,9 @@ import java.time.LocalDate; +/** + * Contains information about a guest staying at the accommodation. + */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/checkout/payments/AccommodationRoom.java b/src/main/java/com/checkout/payments/AccommodationRoom.java index c1b65a45..06ebc142 100644 --- a/src/main/java/com/checkout/payments/AccommodationRoom.java +++ b/src/main/java/com/checkout/payments/AccommodationRoom.java @@ -5,6 +5,9 @@ import lombok.Data; import lombok.NoArgsConstructor; +/** + * Contains information about a room booked by the customer. + */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/checkout/payments/AirlineData.java b/src/main/java/com/checkout/payments/AirlineData.java index 2f378891..bcf4443b 100644 --- a/src/main/java/com/checkout/payments/AirlineData.java +++ b/src/main/java/com/checkout/payments/AirlineData.java @@ -7,15 +7,35 @@ import java.util.List; +/** + * Contains information about the airline ticket and flights booked by the customer. + */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public final class AirlineData { + /** + * Contains information about the airline ticket. + * [Optional] + */ private Ticket ticket; + /** + * Contains information about the passenger(s) on the flight. + * [Optional] + *

+ * The API returns this as an array. Some payment methods, PayPal among them, send a single + * object instead, which the specification allows on the payment sessions, hosted payments + * and payment links interfaces. Both shapes deserialize here; a single object becomes a + * one-element list. Serialization always emits an array. + */ private List passenger; + /** + * Contains information about the flight leg(s) booked by the customer. + * [Optional] + */ private List flightLegDetails; } diff --git a/src/main/java/com/checkout/payments/FlightLegDetails.java b/src/main/java/com/checkout/payments/FlightLegDetails.java index 1951c3e3..21ab0a05 100644 --- a/src/main/java/com/checkout/payments/FlightLegDetails.java +++ b/src/main/java/com/checkout/payments/FlightLegDetails.java @@ -5,28 +5,87 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDate; + +/** + * Contains information about a flight leg booked by the customer. + */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public final class FlightLegDetails { - private Long flightNumber; + /** + * The flight identifier. + * [Optional] + */ + private String flightNumber; + /** + * The IATA 2-letter accounting code (PAX) that identifies the carrier. + * This field is required if the airline data includes leg details. + * [Optional] + */ private String carrierCode; - private String serviceClass; + /** + * A one-letter travel class identifier. The following are common: + * F = First class, J = Business class, Y = Economy class, W = Premium economy. + * [Optional] + */ + private String classOfTravelling; - private String departureDate; + /** + * The IATA three-letter airport code of the departure airport. + * This field is required if the airline data includes leg details. + * [Optional] + */ + private String departureAirport; - private String departureTime; + /** + * The date of the scheduled take off. + * [Optional] + * Format: yyyy-MM-dd + */ + private LocalDate departureDate; - private String departureAirport; + /** + * The time of the scheduled take off. + * [Optional] + */ + private String departureTime; + /** + * The IATA 3-letter airport code of the destination airport. + * This field is required if the airline data includes leg details. + * [Optional] + */ private String arrivalAirport; - private String stopoverCode; + /** + * A one-letter code that indicates whether the passenger is entitled to make a stopover. + * Can be a space, O if the passenger is entitled to make a stopover, or X if they are not. + * [Optional] + */ + private String stopOverCode; + /** + * The fare basis code, alphanumeric. + * [Optional] + */ private String fareBasisCode; + /** + * Not in the current spec, will be removed in a future version. + * Serializes as {@code service_class}, which the API does not define, so the value is + * discarded by the gateway. Use {@link #getClassOfTravelling()} instead, which maps the + * spec property {@code class_of_travelling}. + * + * @deprecated Not defined by the API, the gateway discards it. Use + * {@code classOfTravelling}, which maps {@code class_of_travelling}. + */ + @Deprecated + private String serviceClass; + } diff --git a/src/main/java/com/checkout/payments/Ticket.java b/src/main/java/com/checkout/payments/Ticket.java index 10fce5a3..0907b720 100644 --- a/src/main/java/com/checkout/payments/Ticket.java +++ b/src/main/java/com/checkout/payments/Ticket.java @@ -5,20 +5,53 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDate; + +/** + * Contains information about the airline ticket. + */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public final class Ticket { + /** + * The ticket's unique identifier. + * [Optional] + */ private String number; - private String issueDate; - + /** + * Date the airline ticket was issued. + * [Optional] + * Format: yyyy-MM-dd + */ + private LocalDate issueDate; + + /** + * Carrier code of the ticket issuer. + * [Optional] + */ private String issuingCarrierCode; + /** + * C = Car rental reservation, A = Airline flight reservation, + * B = Both car rental and airline flight reservations included, N = Unknown. + * [Optional] + */ + private String travelPackageIndicator; + + /** + * The name of the travel agency. + * [Optional] + */ private String travelAgencyName; + /** + * The unique identifier from IATA or ARC for the travel agency that issues the ticket. + * [Optional] + */ private String travelAgencyCode; } diff --git a/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationData.java b/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationData.java index 298249d9..198c39a9 100644 --- a/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationData.java +++ b/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationData.java @@ -1,7 +1,6 @@ package com.checkout.payments.contexts; import com.checkout.common.Address; -import com.checkout.common.CountryCode; import com.checkout.common.Phone; import lombok.AllArgsConstructor; import lombok.Builder; @@ -11,6 +10,9 @@ import java.time.LocalDate; import java.util.List; +/** + * Contains information about the accommodation booked by the customer. + */ @Data @Builder @NoArgsConstructor @@ -50,16 +52,23 @@ public final class PaymentContextsAccommodationData { private Address address; /** - * The state of the accommodation. + * The state or province of the address country + * (ISO 3166-2 code of up to two alphanumeric characters). * [Optional] + *

+ * A free-form string, not a country code. The specification's own example is {@code FL}, + * which is a US state rather than a country. */ - private CountryCode state; + private String state; /** - * The country of the accommodation. + * The ISO country code of the address. * [Optional] + *

+ * A free-form string rather than an ISO 3166-1 alpha-2 enum: the specification's example is + * the three-letter code {@code USA}, which no alpha-2 enum can represent. */ - private CountryCode country; + private String country; /** * The city of the accommodation. diff --git a/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationRoom.java b/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationRoom.java index ee623aa8..95a70fb1 100644 --- a/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationRoom.java +++ b/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationRoom.java @@ -5,13 +5,28 @@ import lombok.Data; import lombok.NoArgsConstructor; +/** + * Contains information about a room booked by the customer. + */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public final class PaymentContextsAccommodationRoom { + /** + * For lodging, contains the nightly rate for one room. For cruise, contains the total cost + * of the cruise. + * [Optional] + */ private String rate; - private Integer numberOfNightsAtRoomRate; + /** + * For lodging, contains the number of nights charged at the rate provided in the rate field. + * For cruise, contains the length of the cruise in days. + * [Optional] + *

+ * The specification declares this as a string, not an integer. Its example is {@code "3"}. + */ + private String numberOfNightsAtRoomRate; } diff --git a/src/main/java/com/checkout/payments/contexts/PaymentContextsAirlineData.java b/src/main/java/com/checkout/payments/contexts/PaymentContextsAirlineData.java index c2fff929..98f66a7d 100644 --- a/src/main/java/com/checkout/payments/contexts/PaymentContextsAirlineData.java +++ b/src/main/java/com/checkout/payments/contexts/PaymentContextsAirlineData.java @@ -7,15 +7,33 @@ import java.util.List; +/** + * Contains information about the airline ticket and flights booked by the customer. + */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public final class PaymentContextsAirlineData { + /** + * Contains information about the airline ticket. + * [Optional] + */ private PaymentContextsTicket ticket; + /** + * Contains information about the passenger(s) on the flight. + * [Optional] + *

+ * Deserialization also accepts a single object, which PayPal returns in place of an array; + * it becomes a one-element list. Serialization always emits an array. + */ private List passenger; + /** + * Contains information about the flight leg(s) booked by the customer. + * [Optional] + */ private List flightLegDetails; } diff --git a/src/main/java/com/checkout/payments/contexts/PaymentContextsFlightLegDetails.java b/src/main/java/com/checkout/payments/contexts/PaymentContextsFlightLegDetails.java index 5faf6f20..7b0d6a9d 100644 --- a/src/main/java/com/checkout/payments/contexts/PaymentContextsFlightLegDetails.java +++ b/src/main/java/com/checkout/payments/contexts/PaymentContextsFlightLegDetails.java @@ -7,6 +7,9 @@ import java.time.LocalDate; +/** + * Contains information about a flight leg booked by the customer. + */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/checkout/payments/contexts/PaymentContextsGuests.java b/src/main/java/com/checkout/payments/contexts/PaymentContextsGuests.java index f82e1f5f..20ee993b 100644 --- a/src/main/java/com/checkout/payments/contexts/PaymentContextsGuests.java +++ b/src/main/java/com/checkout/payments/contexts/PaymentContextsGuests.java @@ -7,6 +7,9 @@ import java.time.LocalDate; +/** + * Contains information about a guest staying at the accommodation. + */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/checkout/payments/contexts/PaymentContextsPassenger.java b/src/main/java/com/checkout/payments/contexts/PaymentContextsPassenger.java index e1aa9dad..e8dc721a 100644 --- a/src/main/java/com/checkout/payments/contexts/PaymentContextsPassenger.java +++ b/src/main/java/com/checkout/payments/contexts/PaymentContextsPassenger.java @@ -1,6 +1,6 @@ package com.checkout.payments.contexts; -import com.checkout.common.Address; +import com.checkout.payments.PassengerAddress; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -8,6 +8,9 @@ import java.time.LocalDate; +/** + * Contains information about a passenger on the flight. + */ @Data @Builder @NoArgsConstructor @@ -34,8 +37,8 @@ public final class PaymentContextsPassenger { private LocalDate dateOfBirth; /** - * The passenger's address. + * Contains information about the passenger's address. * [Optional] */ - private Address address; + private PassengerAddress address; } diff --git a/src/main/java/com/checkout/payments/contexts/PaymentContextsTicket.java b/src/main/java/com/checkout/payments/contexts/PaymentContextsTicket.java index fedca284..365be442 100644 --- a/src/main/java/com/checkout/payments/contexts/PaymentContextsTicket.java +++ b/src/main/java/com/checkout/payments/contexts/PaymentContextsTicket.java @@ -7,6 +7,9 @@ import java.time.LocalDate; +/** + * Contains information about the airline ticket. + */ @Data @Builder @NoArgsConstructor diff --git a/src/test/java/com/checkout/GsonSerializerTest.java b/src/test/java/com/checkout/GsonSerializerTest.java index 940a2c67..d6263ee1 100644 --- a/src/test/java/com/checkout/GsonSerializerTest.java +++ b/src/test/java/com/checkout/GsonSerializerTest.java @@ -1,6 +1,7 @@ package com.checkout; import com.checkout.common.PaymentMethodType; +import com.checkout.common.CountryCode; import com.checkout.common.PaymentSourceType; import com.checkout.financial.FinancialActionsQueryResponse; import com.checkout.issuing.cardholders.CardholderCardsResponse; @@ -28,6 +29,7 @@ import org.junit.jupiter.api.DisplayName; import java.time.Instant; +import java.time.LocalDate; import java.io.IOException; import static com.checkout.TestHelper.getMock; @@ -181,6 +183,59 @@ void shouldSerializePaymentDetailsResponseFromJson() { assertNotNull(paymentDetailsResponse.getPaymentPlan()); } + /** + * The same mock, driven for its airline and accommodation sub-tree. + * + *

GET /payments/{id} deserializes into GetPaymentResponse, whose processing is a + * ProcessingData. processing.airline_data[].passenger is an array; SDKs that typed it as a + * single object threw on the whole call for any payment carrying passenger data. Reported + * internally. The mock already carried the correct shape and nothing asserted it: the test + * above only checks getPaymentPlan(). + */ + @Test + void shouldDeserializePaymentDetailsAirlineAndAccommodationSubTree() { + + final com.checkout.payments.response.GetPaymentResponse response = serializer.fromJson(getMock("/mocks/payments/response/plan/get_payment_details_response.json"), com.checkout.payments.response.GetPaymentResponse.class); + + assertNotNull(response.getProcessing()); + + assertNotNull(response.getProcessing().getAirlineData()); + assertEquals(1, response.getProcessing().getAirlineData().size()); + + final com.checkout.payments.AirlineData airline = response.getProcessing().getAirlineData().get(0); + + assertEquals("045-21351455613", airline.getTicket().getNumber()); + assertEquals(LocalDate.of(2023, 5, 20), airline.getTicket().getIssueDate()); + assertEquals("AI", airline.getTicket().getIssuingCarrierCode()); + assertEquals("B", airline.getTicket().getTravelPackageIndicator()); + + assertNotNull(airline.getPassenger()); + assertEquals(1, airline.getPassenger().size()); + assertEquals("John", airline.getPassenger().get(0).getFirstName()); + assertEquals("White", airline.getPassenger().get(0).getLastName()); + assertEquals(LocalDate.of(1990, 5, 26), airline.getPassenger().get(0).getDateOfBirth()); + assertEquals(CountryCode.US, airline.getPassenger().get(0).getAddress().getCountry()); + + final com.checkout.payments.FlightLegDetails leg = airline.getFlightLegDetails().get(0); + + // These three were dropped on the floor before: flight_number was typed as an integer, + // and class_of_travelling / stop_over_code did not exist on the class at all. + assertEquals("101", leg.getFlightNumber()); + assertEquals("J", leg.getClassOfTravelling()); + assertEquals("x", leg.getStopOverCode()); + assertEquals(LocalDate.of(2023, 6, 19), leg.getDepartureDate()); + assertEquals("LHR", leg.getDepartureAirport()); + assertEquals("LAX", leg.getArrivalAirport()); + + assertNotNull(response.getProcessing().getAccommodationData()); + assertEquals("The Sea View Hotel", response.getProcessing().getAccommodationData().get(0).getName()); + // state and country are free-form strings, not country codes. + assertEquals("US", response.getProcessing().getAccommodationData().get(0).getState()); + assertEquals("CA", response.getProcessing().getAccommodationData().get(0).getCountry()); + assertEquals("70", response.getProcessing().getAccommodationData().get(0).getRoom().get(0).getRate()); + assertEquals("3", response.getProcessing().getAccommodationData().get(0).getRoom().get(0).getNumberOfNightsAtRoomRate()); + } + @Test void shouldDeserializeProductWithEnumType() { String json = "{ \"Type\": \"DIGITAL\", \"name\": \"Product Name\" }"; diff --git a/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsIndustrySerializationTest.java b/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsIndustrySerializationTest.java index feba555f..1ab40116 100644 --- a/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsIndustrySerializationTest.java +++ b/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsIndustrySerializationTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import java.time.LocalDate; +import java.util.Arrays; import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -24,7 +25,8 @@ class PaymentSetupsIndustrySerializationTest { @Test void shouldSerializeIndustryUnderCorrectJsonKeys() { final Industry industry = Industry.builder() - .airlineData(AirlineData.builder().totalNumberOfPassengers(2L).build()) + .airlineData(Collections.singletonList( + AirlineData.builder().totalNumberOfPassengers(2L).build())) .accommodationData(Collections.singletonList( AccommodationData.builder().name("Grand Hotel").build())) .build(); @@ -105,4 +107,71 @@ void shouldSerializeAndDeserializeNewAirlineFields() { assertEquals("Acme Insurance", deserialized.getInsurance().getCompany()); assertEquals(Currency.USD, deserialized.getInsurance().getPrice().getCurrency()); } + + // ------------------------------------------------------------------------ + // Industry cardinality + // + // The spec declares both industry.airline and industry.accommodation as arrays. airline was + // modelled as a single object, so it serialized as an object under a key the API expects to + // be an array, and the value never reached the gateway. The keys were already correct; only + // the airline cardinality was wrong. + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeBothIndustryEntriesAsArrays() { + final Industry industry = Industry.builder() + .airlineData(Collections.singletonList( + AirlineData.builder().travelType("international").build())) + .accommodationData(Collections.singletonList( + AccommodationData.builder().name("Grand Hotel").build())) + .build(); + + final String json = serializer.toJson(industry); + + assertTrue(json.contains("\"airline\":[{"), json); + assertTrue(json.contains("\"accommodation\":[{"), json); + assertTrue(!json.contains("\"airline\":{"), json); + assertTrue(!json.contains("\"accommodation\":{"), json); + } + + @Test + void shouldRoundTripMultipleIndustryEntries() { + final Industry industry = Industry.builder() + .airlineData(Arrays.asList( + AirlineData.builder().travelType("domestic").build(), + AirlineData.builder().travelType("international").build())) + .accommodationData(Arrays.asList( + AccommodationData.builder().name("Grand Hotel").build(), + AccommodationData.builder().name("Alpine Lodge").build())) + .build(); + + final Industry result = serializer.fromJson(serializer.toJson(industry), Industry.class); + + assertEquals(2, result.getAirlineData().size()); + assertEquals("domestic", result.getAirlineData().get(0).getTravelType()); + assertEquals("international", result.getAirlineData().get(1).getTravelType()); + assertEquals(2, result.getAccommodationData().size()); + assertEquals("Grand Hotel", result.getAccommodationData().get(0).getName()); + assertEquals("Alpine Lodge", result.getAccommodationData().get(1).getName()); + } + + @Test + void shouldDeserializeIndustryFromTheSpecKeysAsArrays() { + final String json = "{" + + "\"airline\":[{\"travel_type\":\"international\",\"trip_type\":\"round_trip\"}]," + + "\"accommodation\":[{\"name\":\"Grand Hotel\",\"number_of_rooms\":2}]" + + "}"; + + final Industry result = serializer.fromJson(json, Industry.class); + + assertNotNull(result.getAirlineData()); + assertEquals(1, result.getAirlineData().size()); + assertEquals("international", result.getAirlineData().get(0).getTravelType()); + assertEquals("round_trip", result.getAirlineData().get(0).getTripType()); + assertNotNull(result.getAccommodationData()); + assertEquals(1, result.getAccommodationData().size()); + assertEquals("Grand Hotel", result.getAccommodationData().get(0).getName()); + assertEquals(2, result.getAccommodationData().get(0).getNumberOfRooms()); + } + } diff --git a/src/test/java/com/checkout/payments/AccommodationDataSerializationTest.java b/src/test/java/com/checkout/payments/AccommodationDataSerializationTest.java index e600277d..7114293b 100644 --- a/src/test/java/com/checkout/payments/AccommodationDataSerializationTest.java +++ b/src/test/java/com/checkout/payments/AccommodationDataSerializationTest.java @@ -41,7 +41,8 @@ void shouldSerializeWithAllOptionalFields() { .city("Paris") .country(CountryCode.FR) .build()) - .country(CountryCode.FR) + .country("USA") + .state("FL") .city("Paris") .numberOfRooms(2) .guests(Arrays.asList( @@ -87,7 +88,8 @@ void shouldDeserializeFromJson() { + "\"booking_reference\":\"BK-12345\"," + "\"check_in_date\":\"2025-06-01\"," + "\"check_out_date\":\"2025-06-05\"," - + "\"country\":\"FR\"," + + "\"country\":\"USA\"," + + "\"state\":\"FL\"," + "\"city\":\"Paris\"," + "\"number_of_rooms\":2," + "\"guests\":[{" @@ -108,7 +110,10 @@ void shouldDeserializeFromJson() { assertEquals("BK-12345", data.getBookingReference()); assertEquals(LocalDate.of(2025, 6, 1), data.getCheckInDate()); assertEquals(LocalDate.of(2025, 6, 5), data.getCheckOutDate()); - assertEquals(CountryCode.FR, data.getCountry()); + // country and state are free-form strings. Typed as the CountryCode enum they could + // not carry "USA", a three-letter code, or "FL", a US state. + assertEquals("USA", data.getCountry()); + assertEquals("FL", data.getState()); assertEquals("Paris", data.getCity()); assertEquals(2, data.getNumberOfRooms()); assertNotNull(data.getGuests()); @@ -126,7 +131,7 @@ void shouldRoundTripSerialize() { .bookingReference("BK-12345") .checkInDate(LocalDate.of(2025, 6, 1)) .checkOutDate(LocalDate.of(2025, 6, 5)) - .country(CountryCode.FR) + .country("USA") .numberOfRooms(1) .guests(Arrays.asList( AccommodationGuest.builder() @@ -143,7 +148,7 @@ void shouldRoundTripSerialize() { assertEquals("Grand Hotel", deserialized.getName()); assertEquals("BK-12345", deserialized.getBookingReference()); assertEquals(LocalDate.of(2025, 6, 1), deserialized.getCheckInDate()); - assertEquals(CountryCode.FR, deserialized.getCountry()); + assertEquals("USA", deserialized.getCountry()); assertEquals(1, deserialized.getNumberOfRooms()); assertNotNull(deserialized.getGuests()); assertEquals("John", deserialized.getGuests().get(0).getFirstName()); diff --git a/src/test/java/com/checkout/payments/AirlineDataSerializationTest.java b/src/test/java/com/checkout/payments/AirlineDataSerializationTest.java new file mode 100644 index 00000000..2b94a1e3 --- /dev/null +++ b/src/test/java/com/checkout/payments/AirlineDataSerializationTest.java @@ -0,0 +1,263 @@ +package com.checkout.payments; + +import com.checkout.GsonSerializer; +import com.checkout.common.CountryCode; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +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; + +/** + * Serialization tests for {@link AirlineData} and its nested types. + * + *

processing.airline_data[].passenger is an array. Some SDKs typed it as a single object, so + * every GET /payments/{id} response carrying passenger data failed to deserialize. Reported + * internally. Java already had the list, but nothing exercised the field, and the surrounding + * flight-leg shape was still the pre-NAS one: it shipped service_class instead of + * class_of_travelling and stopover_code instead of stop_over_code, both keys the API does not + * define, and flight_number as an integer where the spec declares a string. + * + *

The JSON fixture below is the swagger AirlineData example values, shared byte for byte with + * the equivalent test in the other SDKs so that seven languages assert against one wire shape. + */ +class AirlineDataSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + private static final String AIRLINE_JSON = "{" + + "\"ticket\":{" + + "\"number\":\"045-21351455613\"," + + "\"issue_date\":\"2023-05-20\"," + + "\"issuing_carrier_code\":\"AI\"," + + "\"travel_package_indicator\":\"B\"," + + "\"travel_agency_name\":\"World Tours\"," + + "\"travel_agency_code\":\"01\"" + + "}," + + "\"passenger\":[{" + + "\"first_name\":\"John\"," + + "\"last_name\":\"White\"," + + "\"date_of_birth\":\"1990-05-26\"," + + "\"address\":{\"country\":\"US\"}" + + "}]," + + "\"flight_leg_details\":[{" + + "\"flight_number\":\"101\"," + + "\"carrier_code\":\"BA\"," + + "\"class_of_travelling\":\"J\"," + + "\"departure_airport\":\"LHR\"," + + "\"departure_date\":\"2023-06-19\"," + + "\"departure_time\":\"15:30\"," + + "\"arrival_airport\":\"LAX\"," + + "\"stop_over_code\":\"x\"," + + "\"fare_basis_code\":\"SPRSVR\"" + + "}]" + + "}"; + + @Test + void shouldDeserializeAirlineDataWithPassengerAsAnArray() { + final AirlineData airline = serializer.fromJson(AIRLINE_JSON, AirlineData.class); + + assertNotNull(airline); + + assertNotNull(airline.getTicket()); + assertEquals("045-21351455613", airline.getTicket().getNumber()); + assertEquals(LocalDate.of(2023, 5, 20), airline.getTicket().getIssueDate()); + assertEquals("AI", airline.getTicket().getIssuingCarrierCode()); + // travel_package_indicator was missing from the class entirely. + assertEquals("B", airline.getTicket().getTravelPackageIndicator()); + assertEquals("World Tours", airline.getTicket().getTravelAgencyName()); + assertEquals("01", airline.getTicket().getTravelAgencyCode()); + + assertNotNull(airline.getPassenger()); + assertEquals(1, airline.getPassenger().size()); + assertEquals("John", airline.getPassenger().get(0).getFirstName()); + assertEquals("White", airline.getPassenger().get(0).getLastName()); + assertEquals(LocalDate.of(1990, 5, 26), airline.getPassenger().get(0).getDateOfBirth()); + assertNotNull(airline.getPassenger().get(0).getAddress()); + assertEquals(CountryCode.US, airline.getPassenger().get(0).getAddress().getCountry()); + + assertNotNull(airline.getFlightLegDetails()); + assertEquals(1, airline.getFlightLegDetails().size()); + + final FlightLegDetails leg = airline.getFlightLegDetails().get(0); + // flight_number is a string in the spec, not an integer. + assertEquals("101", leg.getFlightNumber()); + assertEquals("BA", leg.getCarrierCode()); + // class_of_travelling, double l. The SDK used to ship service_class. + assertEquals("J", leg.getClassOfTravelling()); + assertEquals("LHR", leg.getDepartureAirport()); + assertEquals(LocalDate.of(2023, 6, 19), leg.getDepartureDate()); + assertEquals("15:30", leg.getDepartureTime()); + assertEquals("LAX", leg.getArrivalAirport()); + // stop_over_code, three tokens. The SDK used to ship stopover_code. + assertEquals("x", leg.getStopOverCode()); + assertEquals("SPRSVR", leg.getFareBasisCode()); + } + + /** + * The same fixture with passenger replaced by its first element, unchanged. This is the shape + * PayPal sends; the spec allows it on PaymentInterfacesProcessingAirlineData with the note + * "PayPal requires a single object". + */ + @Test + void shouldDeserializeAirlineDataWithPassengerAsASingleObject() { + final String json = "{" + + "\"ticket\":{\"number\":\"045-21351455613\"}," + + "\"passenger\":{" + + "\"first_name\":\"John\"," + + "\"last_name\":\"White\"," + + "\"date_of_birth\":\"1990-05-26\"," + + "\"address\":{\"country\":\"US\"}" + + "}" + + "}"; + + final AirlineData airline = serializer.fromJson(json, AirlineData.class); + + // Normalized to a one-element list, so callers only handle one shape. + assertNotNull(airline.getPassenger()); + assertEquals(1, airline.getPassenger().size()); + assertEquals("John", airline.getPassenger().get(0).getFirstName()); + assertEquals("White", airline.getPassenger().get(0).getLastName()); + assertEquals(LocalDate.of(1990, 5, 26), airline.getPassenger().get(0).getDateOfBirth()); + assertEquals(CountryCode.US, airline.getPassenger().get(0).getAddress().getCountry()); + } + + @Test + void shouldDeserializeAirlineDataWithNoPassengerAtAll() { + final String json = "{\"ticket\":{\"number\":\"045\"},\"passenger\":null}"; + + final AirlineData airline = serializer.fromJson(json, AirlineData.class); + + assertNotNull(airline.getTicket()); + assertNull(airline.getPassenger()); + } + + @Test + void shouldDeserializeMultiplePassengers() { + final String json = "{\"passenger\":[" + + "{\"first_name\":\"John\"}," + + "{\"first_name\":\"Jane\"}" + + "]}"; + + final AirlineData airline = serializer.fromJson(json, AirlineData.class); + + assertEquals(2, airline.getPassenger().size()); + assertEquals("John", airline.getPassenger().get(0).getFirstName()); + assertEquals("Jane", airline.getPassenger().get(1).getFirstName()); + } + + /** + * Only a deserializer is registered for the single-or-array shape, never a serializer, so + * writing always goes through Gson's reflective adapter and emits an array. If a serializer + * were ever added, every request carrying airline data would change shape and this fails. + */ + @Test + void shouldAlwaysSerializePassengerAsAnArray() { + final AirlineData airline = AirlineData.builder() + .passenger(Collections.singletonList( + Passenger.builder().firstName("John").lastName("White").build())) + .build(); + + final String json = serializer.toJson(airline); + + assertTrue(json.contains("\"passenger\":[{"), json); + assertFalse(json.contains("\"passenger\":{"), json); + } + + @Test + void shouldRoundTripSerializeAirlineData() { + final AirlineData original = AirlineData.builder() + .ticket(Ticket.builder() + .number("045-21351455613") + .issueDate(LocalDate.of(2023, 5, 20)) + .travelPackageIndicator("B") + .build()) + .passenger(Arrays.asList( + Passenger.builder() + .firstName("John") + .dateOfBirth(LocalDate.of(1990, 5, 26)) + .build(), + Passenger.builder().firstName("Jane").build())) + .flightLegDetails(Collections.singletonList( + FlightLegDetails.builder() + .flightNumber("101") + .classOfTravelling("J") + .stopOverCode("x") + .departureDate(LocalDate.of(2023, 6, 19)) + .build())) + .build(); + + final AirlineData result = serializer.fromJson(serializer.toJson(original), AirlineData.class); + + assertEquals("045-21351455613", result.getTicket().getNumber()); + assertEquals(LocalDate.of(2023, 5, 20), result.getTicket().getIssueDate()); + assertEquals("B", result.getTicket().getTravelPackageIndicator()); + assertEquals(2, result.getPassenger().size()); + assertEquals("John", result.getPassenger().get(0).getFirstName()); + assertEquals(LocalDate.of(1990, 5, 26), result.getPassenger().get(0).getDateOfBirth()); + assertEquals("Jane", result.getPassenger().get(1).getFirstName()); + assertEquals("101", result.getFlightLegDetails().get(0).getFlightNumber()); + assertEquals("J", result.getFlightLegDetails().get(0).getClassOfTravelling()); + assertEquals("x", result.getFlightLegDetails().get(0).getStopOverCode()); + assertEquals(LocalDate.of(2023, 6, 19), result.getFlightLegDetails().get(0).getDepartureDate()); + } + + /** + * Asserts on the serialized string, so a future rename cannot pass silently. These keys were + * all wrong at some point and each one was dropped by the gateway. + */ + @Test + void shouldSerializeAirlineKeysExactlyAsTheSpecNamesThem() { + final AirlineData airline = AirlineData.builder() + .ticket(Ticket.builder() + .issueDate(LocalDate.of(2023, 5, 20)) + .travelPackageIndicator("B") + .build()) + .passenger(Collections.singletonList(Passenger.builder().firstName("John").build())) + .flightLegDetails(Collections.singletonList( + FlightLegDetails.builder() + .flightNumber("101") + .classOfTravelling("J") + .stopOverCode("x") + .departureDate(LocalDate.of(2023, 6, 19)) + .build())) + .build(); + + final String json = serializer.toJson(airline); + + assertTrue(json.contains("\"class_of_travelling\":\"J\""), json); + assertTrue(json.contains("\"stop_over_code\":\"x\""), json); + assertTrue(json.contains("\"flight_number\":\"101\""), json); + assertTrue(json.contains("\"issue_date\":\"2023-05-20\""), json); + assertTrue(json.contains("\"travel_package_indicator\":\"B\""), json); + assertTrue(json.contains("\"departure_date\":\"2023-06-19\""), json); + + // Keys the API does not define. + assertFalse(json.contains("service_class"), json); + assertFalse(json.contains("\"stopover_code\""), json); + } + + /** + * stopoverCode was removed, so stopover_code is now unreachable: no caller can put that key + * on the wire. serviceClass is retained but deprecated. + */ + @Test + void shouldNotExposeTheRemovedStopoverCodeField() throws Exception { + assertNotNull(FlightLegDetails.class.getDeclaredField("stopOverCode")); + assertNotNull(FlightLegDetails.class.getDeclaredField("serviceClass")); + + boolean removed = false; + try { + FlightLegDetails.class.getDeclaredField("stopoverCode"); + } catch (final NoSuchFieldException expected) { + removed = true; + } + assertTrue(removed, "stopoverCode should no longer exist on FlightLegDetails"); + } +} diff --git a/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java b/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java new file mode 100644 index 00000000..14228bbb --- /dev/null +++ b/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java @@ -0,0 +1,181 @@ +package com.checkout.payments.contexts; + +import com.checkout.GsonSerializer; +import com.checkout.common.CountryCode; +import com.checkout.payments.PassengerAddress; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +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.assertTrue; + +/** + * Serialization tests for the payment contexts airline and accommodation sub-tree. + * + *

Covers the cardinality of airline_data[].ticket (a single object) and + * airline_data[].passenger (an array that also accepts a bare object), and the two nested + * shapes whose types disagreed with the spec: accommodation state/country were typed as the + * CountryCode enum where the spec declares free-form strings, and + * room[].number_of_nights_at_room_rate was an integer where the spec declares a string. + */ +class PaymentContextsAirlineSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + @Test + void shouldSerializeAirlineTicketAsAnObjectAndPassengerAsAnArray() { + final PaymentContextsAirlineData airline = PaymentContextsAirlineData.builder() + .ticket(PaymentContextsTicket.builder() + .number("045-21351455613") + .issueDate(LocalDate.of(2023, 5, 20)) + .build()) + .passenger(Collections.singletonList( + PaymentContextsPassenger.builder().firstName("John").build())) + .flightLegDetails(Collections.singletonList( + PaymentContextsFlightLegDetails.builder() + .flightNumber("101") + .classOfTravelling("J") + .stopOverCode("x") + .build())) + .build(); + + final String json = serializer.toJson(airline); + + assertTrue(json.contains("\"ticket\":{"), json); + assertFalse(json.contains("\"ticket\":["), json); + assertTrue(json.contains("\"passenger\":[{"), json); + assertTrue(json.contains("\"class_of_travelling\":\"J\""), json); + assertTrue(json.contains("\"stop_over_code\":\"x\""), json); + assertTrue(json.contains("\"flight_number\":\"101\""), json); + } + + @Test + void shouldDeserializeAirlineTicketFromAnObject() { + final String json = "{" + + "\"ticket\":{\"number\":\"045-21351455613\",\"issue_date\":\"2023-05-20\"}," + + "\"passenger\":[{\"first_name\":\"John\"}]" + + "}"; + + final PaymentContextsAirlineData airline = + serializer.fromJson(json, PaymentContextsAirlineData.class); + + assertNotNull(airline.getTicket()); + assertEquals("045-21351455613", airline.getTicket().getNumber()); + assertEquals(LocalDate.of(2023, 5, 20), airline.getTicket().getIssueDate()); + assertEquals(1, airline.getPassenger().size()); + assertEquals("John", airline.getPassenger().get(0).getFirstName()); + } + + /** + * PayPal is a payment contexts payment method and returns passenger as a bare object. + */ + @Test + void shouldDeserializeAirlinePassengerFromASingleObject() { + final String json = "{" + + "\"ticket\":{\"number\":\"045\"}," + + "\"passenger\":{\"first_name\":\"John\",\"date_of_birth\":\"1990-05-26\"," + + "\"address\":{\"country\":\"US\"}}" + + "}"; + + final PaymentContextsAirlineData airline = + serializer.fromJson(json, PaymentContextsAirlineData.class); + + assertNotNull(airline.getPassenger()); + assertEquals(1, airline.getPassenger().size()); + assertEquals("John", airline.getPassenger().get(0).getFirstName()); + assertEquals(LocalDate.of(1990, 5, 26), airline.getPassenger().get(0).getDateOfBirth()); + assertEquals(CountryCode.US, airline.getPassenger().get(0).getAddress().getCountry()); + } + + @Test + void shouldRoundTripSerializeAirlineData() { + final PaymentContextsAirlineData original = PaymentContextsAirlineData.builder() + .ticket(PaymentContextsTicket.builder() + .number("045") + .travelPackageIndicator("B") + .build()) + .passenger(Arrays.asList( + PaymentContextsPassenger.builder() + .firstName("John") + .address(PassengerAddress.builder().country(CountryCode.GB).build()) + .build(), + PaymentContextsPassenger.builder().firstName("Jane").build())) + .build(); + + final PaymentContextsAirlineData result = + serializer.fromJson(serializer.toJson(original), PaymentContextsAirlineData.class); + + assertEquals("045", result.getTicket().getNumber()); + assertEquals("B", result.getTicket().getTravelPackageIndicator()); + assertEquals(2, result.getPassenger().size()); + assertEquals(CountryCode.GB, result.getPassenger().get(0).getAddress().getCountry()); + assertEquals("Jane", result.getPassenger().get(1).getFirstName()); + } + + /** + * passenger.address defines exactly one property, country. It was the wider common Address, + * whose other members the API does not read here. + */ + @Test + void shouldSerializeOnlyCountryOnThePassengerAddress() { + final String json = serializer.toJson(PaymentContextsPassenger.builder() + .firstName("John") + .address(PassengerAddress.builder().country(CountryCode.US).build()) + .build()); + + assertTrue(json.contains("\"address\":{\"country\":\"US\"}"), json); + assertFalse(json.contains("address_line1"), json); + assertFalse(json.contains("\"zip\""), json); + } + + @Test + void shouldSerializeNumberOfNightsAtRoomRateAsAString() { + final String json = serializer.toJson(PaymentContextsAccommodationRoom.builder() + .rate("70") + .numberOfNightsAtRoomRate("3") + .build()); + + assertTrue(json.contains("\"rate\":\"70\""), json); + assertTrue(json.contains("\"number_of_nights_at_room_rate\":\"3\""), json); + } + + @Test + void shouldDeserializeNumberOfNightsAtRoomRateFromAString() { + final String json = "{\"rate\":\"70\",\"number_of_nights_at_room_rate\":\"3\"}"; + + final PaymentContextsAccommodationRoom room = + serializer.fromJson(json, PaymentContextsAccommodationRoom.class); + + assertEquals("70", room.getRate()); + assertEquals("3", room.getNumberOfNightsAtRoomRate()); + } + + /** + * state and country are free-form strings. Typed as the CountryCode enum they could not carry + * "FL", a US state, or "USA", a three-letter code. + */ + @Test + void shouldDeserializeAccommodationStateAndCountryAsFreeFormStrings() { + final String json = "{" + + "\"name\":\"The Sea View Hotel\"," + + "\"state\":\"FL\"," + + "\"country\":\"USA\"," + + "\"city\":\"Los Angeles\"," + + "\"room\":[{\"rate\":\"70\",\"number_of_nights_at_room_rate\":\"3\"}]" + + "}"; + + final PaymentContextsAccommodationData data = + serializer.fromJson(json, PaymentContextsAccommodationData.class); + + assertEquals("The Sea View Hotel", data.getName()); + assertEquals("FL", data.getState()); + assertEquals("USA", data.getCountry()); + assertEquals("Los Angeles", data.getCity()); + assertEquals("3", data.getRoom().get(0).getNumberOfNightsAtRoomRate()); + } +} diff --git a/src/test/java/com/checkout/payments/response/ProcessingDataDeserializationTest.java b/src/test/java/com/checkout/payments/response/ProcessingDataDeserializationTest.java index 1165786d..80c1d160 100644 --- a/src/test/java/com/checkout/payments/response/ProcessingDataDeserializationTest.java +++ b/src/test/java/com/checkout/payments/response/ProcessingDataDeserializationTest.java @@ -1,7 +1,9 @@ package com.checkout.payments.response; import com.checkout.GsonSerializer; +import com.checkout.common.CountryCode; import org.junit.jupiter.api.Test; +import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -167,4 +169,136 @@ void shouldLeaveNewFieldsNullWhenAbsent() { assertNull(data.getPartnerResponseCode()); assertNull(data.getSchemeTransactionLinkId()); } + + // ------------------------------------------------------------------------ + // airline_data / accommodation_data, full sub-tree + // + // shouldDeserializeAirlineData below only ever supplied {"ticket":{"number":...}}, so it + // never touched passenger and stayed green through the whole life of the cardinality defect. + // The fixture here is the swagger AirlineData / AccommodationData example values, shared + // with the equivalent test in the other SDKs. + // ------------------------------------------------------------------------ + + private static final String AIRLINE_AND_ACCOMMODATION_JSON = "{" + + "\"airline_data\":[{" + + "\"ticket\":{" + + "\"number\":\"045-21351455613\"," + + "\"issue_date\":\"2023-05-20\"," + + "\"issuing_carrier_code\":\"AI\"," + + "\"travel_package_indicator\":\"B\"," + + "\"travel_agency_name\":\"World Tours\"," + + "\"travel_agency_code\":\"01\"" + + "}," + + "\"passenger\":[{" + + "\"first_name\":\"John\",\"last_name\":\"White\"," + + "\"date_of_birth\":\"1990-05-26\",\"address\":{\"country\":\"US\"}" + + "}]," + + "\"flight_leg_details\":[{" + + "\"flight_number\":\"101\",\"carrier_code\":\"BA\"," + + "\"class_of_travelling\":\"J\",\"departure_airport\":\"LHR\"," + + "\"departure_date\":\"2023-06-19\",\"departure_time\":\"15:30\"," + + "\"arrival_airport\":\"LAX\",\"stop_over_code\":\"x\"," + + "\"fare_basis_code\":\"SPRSVR\"" + + "}]" + + "}]," + + "\"accommodation_data\":[{" + + "\"name\":\"The Sea View Hotel\"," + + "\"booking_reference\":\"HOTEL123\"," + + "\"check_in_date\":\"2023-06-20\"," + + "\"check_out_date\":\"2023-06-23\"," + + "\"address\":{\"address_line1\":\"123 Beach Road\",\"zip\":\"10001\"}," + + "\"state\":\"FL\",\"country\":\"USA\",\"city\":\"Los Angeles\"," + + "\"number_of_rooms\":2," + + "\"guests\":[{\"first_name\":\"Jane\",\"last_name\":\"Doe\"," + + "\"date_of_birth\":\"1985-07-14\"}]," + + "\"room\":[{\"rate\":\"70\",\"number_of_nights_at_room_rate\":\"3\"}]," + + "\"property_phone\":[{\"country_code\":\"44\",\"number\":\"7123456789\"}]," + + "\"customer_service_phone\":[{\"country_code\":\"44\",\"number\":\"7987654321\"}]" + + "}]" + + "}"; + + @Test + void shouldDeserializeAirlineDataWithPassengerAsAnArray() { + final ProcessingData data = + serializer.fromJson(AIRLINE_AND_ACCOMMODATION_JSON, ProcessingData.class); + + assertNotNull(data.getAirlineData()); + assertEquals(1, data.getAirlineData().size()); + + assertEquals("045-21351455613", data.getAirlineData().get(0).getTicket().getNumber()); + assertEquals(LocalDate.of(2023, 5, 20), + data.getAirlineData().get(0).getTicket().getIssueDate()); + assertEquals("B", data.getAirlineData().get(0).getTicket().getTravelPackageIndicator()); + + assertNotNull(data.getAirlineData().get(0).getPassenger()); + assertEquals(1, data.getAirlineData().get(0).getPassenger().size()); + assertEquals("John", data.getAirlineData().get(0).getPassenger().get(0).getFirstName()); + assertEquals(LocalDate.of(1990, 5, 26), + data.getAirlineData().get(0).getPassenger().get(0).getDateOfBirth()); + assertEquals(CountryCode.US, + data.getAirlineData().get(0).getPassenger().get(0).getAddress().getCountry()); + + assertEquals("101", + data.getAirlineData().get(0).getFlightLegDetails().get(0).getFlightNumber()); + assertEquals("J", + data.getAirlineData().get(0).getFlightLegDetails().get(0).getClassOfTravelling()); + assertEquals("x", + data.getAirlineData().get(0).getFlightLegDetails().get(0).getStopOverCode()); + assertEquals(LocalDate.of(2023, 6, 19), + data.getAirlineData().get(0).getFlightLegDetails().get(0).getDepartureDate()); + } + + @Test + void shouldDeserializeAccommodationDataFromTheSameFixture() { + final ProcessingData data = + serializer.fromJson(AIRLINE_AND_ACCOMMODATION_JSON, ProcessingData.class); + + assertNotNull(data.getAccommodationData()); + assertEquals(1, data.getAccommodationData().size()); + + assertEquals("The Sea View Hotel", data.getAccommodationData().get(0).getName()); + assertEquals("HOTEL123", data.getAccommodationData().get(0).getBookingReference()); + assertEquals(LocalDate.of(2023, 6, 20), data.getAccommodationData().get(0).getCheckInDate()); + assertEquals(LocalDate.of(2023, 6, 23), data.getAccommodationData().get(0).getCheckOutDate()); + assertEquals("123 Beach Road", + data.getAccommodationData().get(0).getAddress().getAddressLine1()); + assertEquals("Los Angeles", data.getAccommodationData().get(0).getCity()); + assertEquals(2, data.getAccommodationData().get(0).getNumberOfRooms()); + + // state and country are free-form strings, not country codes. + assertEquals("FL", data.getAccommodationData().get(0).getState()); + assertEquals("USA", data.getAccommodationData().get(0).getCountry()); + + assertEquals("Jane", data.getAccommodationData().get(0).getGuests().get(0).getFirstName()); + assertEquals(LocalDate.of(1985, 7, 14), + data.getAccommodationData().get(0).getGuests().get(0).getDateOfBirth()); + assertEquals("70", data.getAccommodationData().get(0).getRoom().get(0).getRate()); + assertEquals("3", + data.getAccommodationData().get(0).getRoom().get(0).getNumberOfNightsAtRoomRate()); + assertEquals("7123456789", + data.getAccommodationData().get(0).getPropertyPhone().get(0).getNumber()); + assertEquals("7987654321", + data.getAccommodationData().get(0).getCustomerServicePhone().get(0).getNumber()); + } + + /** + * Regression: this used to fail when passenger came back as an array in SDKs that typed it as + * a single object, and it still cannot be exercised unless the field is populated. Reported + * internally. + */ + @Test + void shouldDeserializeAirlinePassengerFromASingleObjectOnProcessingData() { + final String json = "{\"airline_data\":[{" + + "\"ticket\":{\"number\":\"045\"}," + + "\"passenger\":{\"first_name\":\"John\",\"date_of_birth\":\"1990-05-26\"}" + + "}]}"; + + final ProcessingData data = serializer.fromJson(json, ProcessingData.class); + + assertEquals(1, data.getAirlineData().get(0).getPassenger().size()); + assertEquals("John", data.getAirlineData().get(0).getPassenger().get(0).getFirstName()); + assertEquals(LocalDate.of(1990, 5, 26), + data.getAirlineData().get(0).getPassenger().get(0).getDateOfBirth()); + } + } diff --git a/src/test/java/com/checkout/serialization/CustomDeserializerPolicyTest.java b/src/test/java/com/checkout/serialization/CustomDeserializerPolicyTest.java index a9c656dd..16c35e9b 100644 --- a/src/test/java/com/checkout/serialization/CustomDeserializerPolicyTest.java +++ b/src/test/java/com/checkout/serialization/CustomDeserializerPolicyTest.java @@ -4,16 +4,21 @@ import com.checkout.ItemsResponse; import com.checkout.accounts.payout.schedule.response.GetScheduleResponse; import com.checkout.common.Currency; +import com.checkout.common.CountryCode; import com.checkout.payments.PaymentAction; +import com.checkout.payments.Passenger; +import com.checkout.payments.AirlineData; import com.checkout.payments.ProductResponse; import com.google.gson.reflect.TypeToken; import org.junit.jupiter.api.Test; import java.lang.reflect.Type; import java.time.LocalDate; +import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -109,4 +114,70 @@ void paymentActionsDeserializer_deserializesArrayWithPolicyMappedFields() { assertEquals("Approved", first.getResponseSummary()); assertEquals("ref-1", first.getReference()); } + + /** + * singleOrArrayDeserializer normalizes the oneOf[array, object] shape of + * processing.airline_data[].passenger into a list. It delegates each element to the default + * (policy-aware) Gson, so snake_case keys map to annotation-less camelCase fields and the + * LocalDate adapter still applies. It must never map property names itself. + */ + @Test + void singleOrArrayDeserializer_honorsNamingPolicyForASingleObject() { + final String json = "{\"passenger\":{" + + "\"first_name\":\"John\"," + + "\"last_name\":\"White\"," + + "\"date_of_birth\":\"1990-05-26\"," + + "\"address\":{\"country\":\"US\"}" + + "}}"; + + final AirlineData airline = serializer.fromJson(json, AirlineData.class); + + assertNotNull(airline.getPassenger()); + assertEquals(1, airline.getPassenger().size()); + assertEquals("John", airline.getPassenger().get(0).getFirstName()); + assertEquals("White", airline.getPassenger().get(0).getLastName()); + assertEquals(LocalDate.of(1990, 5, 26), airline.getPassenger().get(0).getDateOfBirth()); + assertEquals(CountryCode.US, airline.getPassenger().get(0).getAddress().getCountry()); + } + + @Test + void singleOrArrayDeserializer_honorsNamingPolicyForAnArray() { + final String json = "{\"passenger\":[" + + "{\"first_name\":\"John\",\"date_of_birth\":\"1990-05-26\"}," + + "{\"first_name\":\"Jane\",\"date_of_birth\":\"1992-01-03\"}" + + "]}"; + + final AirlineData airline = serializer.fromJson(json, AirlineData.class); + + assertEquals(2, airline.getPassenger().size()); + assertEquals("John", airline.getPassenger().get(0).getFirstName()); + assertEquals(LocalDate.of(1990, 5, 26), airline.getPassenger().get(0).getDateOfBirth()); + assertEquals("Jane", airline.getPassenger().get(1).getFirstName()); + assertEquals(LocalDate.of(1992, 1, 3), airline.getPassenger().get(1).getDateOfBirth()); + } + + /** + * Only a deserializer is registered, so writing goes through the reflective adapter and + * always emits an array. Registering a serializer would silently change every outbound + * request that carries airline data. + */ + @Test + void singleOrArrayDeserializer_isReadOnlySoWritesStayAnArray() { + final String json = serializer.toJson(AirlineData.builder() + .passenger(Collections.singletonList( + Passenger.builder().firstName("John").build())) + .build()); + + assertTrue(json.contains("\"passenger\":[{"), json); + assertTrue(!json.contains("\"passenger\":{"), json); + } + + @Test + void singleOrArrayDeserializer_readsNullAsNull() { + final AirlineData airline = + serializer.fromJson("{\"passenger\":null}", AirlineData.class); + + assertNull(airline.getPassenger()); + } + } From e0997d504e45082875074a8ffb56b528d2868814 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Fri, 25 Sep 2026 11:21:59 +0200 Subject: [PATCH 2/4] JDoc + model adjustments + tests --- .../java/com/checkout/GsonSerializer.java | 5 ++ .../setups/entities/industry/Industry.java | 18 ++--- .../checkout/payments/AccommodationData.java | 6 +- .../payments/PartnerCustomerRiskData.java | 28 ++++++++ .../checkout/payments/ProcessingSettings.java | 26 +++++++ .../PaymentContextsAccommodationData.java | 8 +++ .../contexts/PaymentContextsProcessing.java | 68 ++++++++++++++++++- .../payments/response/ProcessingData.java | 6 ++ ...aymentSetupsIndustrySerializationTest.java | 40 +++++------ .../ProcessingSettingsSerializationTest.java | 43 ++++++++++++ ...ymentContextsAirlineSerializationTest.java | 42 +++++++++++- 11 files changed, 257 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/checkout/payments/PartnerCustomerRiskData.java diff --git a/src/main/java/com/checkout/GsonSerializer.java b/src/main/java/com/checkout/GsonSerializer.java index 6fca0252..dc76edca 100644 --- a/src/main/java/com/checkout/GsonSerializer.java +++ b/src/main/java/com/checkout/GsonSerializer.java @@ -110,6 +110,11 @@ public final class GsonSerializer implements Serializer { .registerTypeAdapter(LocalDate.class, getLocalDateJsonDeserializer()) // processing.airline_data[].passenger is oneOf[array, object]: PayPal returns a // single object where the array shape is declared. Read both, always write an array. + // + // Bound by element type, so the second registration also covers PaymentSetupAirline + // .passengers, which the spec declares array-only. Accepting a bare object there is + // wider than the spec grants but cannot lose data, and TypeToken cannot distinguish + // the two call sites. Deliberate; revisit only if a wrapper type is introduced. .registerTypeAdapter( new TypeToken>() { }.getType(), diff --git a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/industry/Industry.java b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/industry/Industry.java index 5e6db1c4..3840574b 100644 --- a/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/industry/Industry.java +++ b/src/main/java/com/checkout/handlepaymentsandpayouts/setups/entities/industry/Industry.java @@ -1,6 +1,5 @@ package com.checkout.handlepaymentsandpayouts.setups.entities.industry; -import com.google.gson.annotations.SerializedName; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -9,7 +8,7 @@ import java.util.List; /** - * Industry-specific payment setup information + * Industry-specific information. */ @Data @Builder @@ -21,17 +20,18 @@ public final class Industry { * Airline industry-specific data for flight bookings and related payments. * [Optional] *

- * The specification declares {@code industry.airline} as an array. This was previously a - * single object, so it serialized as the object {@code airline}, a shape the API does not - * accept, meaning the value never reached the gateway. + * Maps the specification property {@code airline}, which is an array. This was previously a + * single object named {@code airlineData}, so it needed an explicit serialized-name override + * to reach the right key at all, and it serialized as an object where the API expects an + * array, meaning the value never reached the gateway. */ - @SerializedName("airline") - private List airlineData; + private List airline; /** * Accommodation industry-specific data for hotel and cruise bookings and related payments. * [Optional] + *

+ * Maps the specification property {@code accommodation}. */ - @SerializedName("accommodation") - private List accommodationData; + private List accommodation; } \ No newline at end of file diff --git a/src/main/java/com/checkout/payments/AccommodationData.java b/src/main/java/com/checkout/payments/AccommodationData.java index 05127eb8..d9406d32 100644 --- a/src/main/java/com/checkout/payments/AccommodationData.java +++ b/src/main/java/com/checkout/payments/AccommodationData.java @@ -46,8 +46,12 @@ public final class AccommodationData { private LocalDate checkOutDate; /** - * The address of the accommodation property. + * The address details of the accommodation. * [Optional] + *

+ * The specification defines only {@code address_line1} and {@code zip} on this object. The + * wider {@link Address} type is reused for consistency with the rest of the SDK; the + * remaining members are not read by the API on this property. */ private Address address; diff --git a/src/main/java/com/checkout/payments/PartnerCustomerRiskData.java b/src/main/java/com/checkout/payments/PartnerCustomerRiskData.java new file mode 100644 index 00000000..0f917b38 --- /dev/null +++ b/src/main/java/com/checkout/payments/PartnerCustomerRiskData.java @@ -0,0 +1,28 @@ +package com.checkout.payments; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A key-and-value pair with merchant-specific data for the transaction. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public final class PartnerCustomerRiskData { + + /** + * The key for the pair. + * [Optional] + */ + private String key; + + /** + * The value for the pair. + * [Optional] + */ + private String value; +} diff --git a/src/main/java/com/checkout/payments/ProcessingSettings.java b/src/main/java/com/checkout/payments/ProcessingSettings.java index 27512718..156ba174 100644 --- a/src/main/java/com/checkout/payments/ProcessingSettings.java +++ b/src/main/java/com/checkout/payments/ProcessingSettings.java @@ -12,6 +12,14 @@ import java.util.List; import java.util.Map; +/** + * Settings that control how the payment is processed. + *

+ * Shared across several request shapes. {@code POST /payments} resolves to + * {@code PaymentRequestProcessing}, while hosted payments, payment links and payment sessions + * resolve to the wider {@code PaymentInterfacesProcessing}. A field is therefore not necessarily + * read by every endpoint that accepts this object; the fields below name the exceptions. + */ @Data @Builder @NoArgsConstructor @@ -261,10 +269,28 @@ public final class ProcessingSettings { */ private Aggregator aggregator; + /** + * A key-and-value pair with merchant-specific data for the transaction. + * [Optional] + *

+ * Declared on {@code PaymentInterfacesProcessing}, so it is read by hosted payments, payment + * links and payment sessions, and not by {@code POST /payments}. The specification types it + * as a single object while describing it as "an array of key-and-value pairs"; the declared + * type is followed here. Payment contexts model the array form as + * {@code List}. + */ + private PartnerCustomerRiskData partnerCustomerRiskData; + /** * The origination country for hub model payments. * [Optional] + * + * @deprecated Not in the current specification. Serializes as + * {@code hub_model_origination_country}, which appears in neither the current (NAS) nor the + * Previous (ABC) spec, so the gateway discards it. Retained for backwards compatibility and + * will be removed in a future version. */ + @Deprecated private CountryCode hubModelOriginationCountry; /** diff --git a/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationData.java b/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationData.java index 198c39a9..448af959 100644 --- a/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationData.java +++ b/src/main/java/com/checkout/payments/contexts/PaymentContextsAccommodationData.java @@ -12,7 +12,15 @@ /** * Contains information about the accommodation booked by the customer. + * + * @deprecated Duplicates {@link com.checkout.payments.AccommodationData}, which maps the same + * specification schema. Payment contexts, {@code POST /payments} and the + * {@code GET /payments/{id}} response all resolve {@code accommodation_data} to that one schema, + * and maintaining two classes for it let them drift. Use + * {@link com.checkout.payments.AccommodationData} instead. Retained for backwards compatibility + * and will be removed in a future version. */ +@Deprecated @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/checkout/payments/contexts/PaymentContextsProcessing.java b/src/main/java/com/checkout/payments/contexts/PaymentContextsProcessing.java index a1117fb9..72ad08cc 100644 --- a/src/main/java/com/checkout/payments/contexts/PaymentContextsProcessing.java +++ b/src/main/java/com/checkout/payments/contexts/PaymentContextsProcessing.java @@ -1,5 +1,6 @@ package com.checkout.payments.contexts; +import com.checkout.payments.AccommodationData; import com.checkout.payments.BillingPlan; import com.checkout.payments.ShippingPreference; import com.checkout.payments.UserAction; @@ -10,36 +11,101 @@ import java.util.List; +/** + * Settings that control how the payment context is processed. + */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public final class PaymentContextsProcessing { + /** + * The plan details for a recurring payment with PayPal. + * Required when {@code payment_type} is {@code recurring}. + * [Optional] + */ private BillingPlan plan; + /** + * The discount amount the merchant applied to the transaction. + * [Optional] + */ private Integer discountAmount; + /** + * The total freight or shipping and handling charges for the transaction. + * [Optional] + */ private Integer shippingAmount; + /** + * The total tax amount for the transaction, in the minor currency unit. + * [Optional] + */ private Integer taxAmount; + /** + * Invoice ID number. + * [Optional] + */ private String invoiceId; + /** + * The label that overrides the business name in the PayPal account on the PayPal pages. + * [Optional] + */ private String brandName; + /** + * The language and region of the customer in ISO 639-2 language code; the value consists of + * language-country. + * [Optional] + */ private String locale; + /** + * Shipping preference. + * [Optional] + * One of: no_shipping, set_provided_address, get_from_file + */ private ShippingPreference shippingPreference; + /** + * Property required by PayPal to have an appropriate payment flow. + * [Optional] + * One of: pay_now, continue + */ private UserAction userAction; + /** + * Key-and-value pairs with merchant-specific data for the transaction. + * [Optional] + */ private List partnerCustomerRiskData; + /** + * Promo codes. Defines which of the configured payment options within a payment category + * (pay_later, pay_over_time, and so on) are shown for this purchase. + * [Optional] + */ private List customPaymentMethodIds; + /** + * Contains information about the airline ticket and flights booked by the customer. + * [Optional] + */ private List airlineData; - private List accommodationData; + /** + * Contains information about the accommodation booked by the customer. + * [Optional] + *

+ * Uses the shared {@link AccommodationData}, because payment contexts, {@code POST /payments} + * and the {@code GET /payments/{id}} response all resolve {@code accommodation_data} to the + * same specification schema. It was previously a separate + * {@link PaymentContextsAccommodationData}, which had already drifted from its twin. + */ + private List accommodationData; } diff --git a/src/main/java/com/checkout/payments/response/ProcessingData.java b/src/main/java/com/checkout/payments/response/ProcessingData.java index c64b44d2..c1a633c1 100644 --- a/src/main/java/com/checkout/payments/response/ProcessingData.java +++ b/src/main/java/com/checkout/payments/response/ProcessingData.java @@ -9,6 +9,12 @@ import java.util.List; +/** + * Returns information related to the processing of the payment. + *

+ * The response counterpart of {@link com.checkout.payments.ProcessingSettings}, returned under + * {@code processing} on {@code GET /payments/{id}}. + */ @Data public final class ProcessingData { diff --git a/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsIndustrySerializationTest.java b/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsIndustrySerializationTest.java index 1ab40116..edcaf925 100644 --- a/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsIndustrySerializationTest.java +++ b/src/test/java/com/checkout/handlepaymentsandpayouts/setups/PaymentSetupsIndustrySerializationTest.java @@ -25,9 +25,9 @@ class PaymentSetupsIndustrySerializationTest { @Test void shouldSerializeIndustryUnderCorrectJsonKeys() { final Industry industry = Industry.builder() - .airlineData(Collections.singletonList( + .airline(Collections.singletonList( AirlineData.builder().totalNumberOfPassengers(2L).build())) - .accommodationData(Collections.singletonList( + .accommodation(Collections.singletonList( AccommodationData.builder().name("Grand Hotel").build())) .build(); @@ -120,9 +120,9 @@ void shouldSerializeAndDeserializeNewAirlineFields() { @Test void shouldSerializeBothIndustryEntriesAsArrays() { final Industry industry = Industry.builder() - .airlineData(Collections.singletonList( + .airline(Collections.singletonList( AirlineData.builder().travelType("international").build())) - .accommodationData(Collections.singletonList( + .accommodation(Collections.singletonList( AccommodationData.builder().name("Grand Hotel").build())) .build(); @@ -137,22 +137,22 @@ void shouldSerializeBothIndustryEntriesAsArrays() { @Test void shouldRoundTripMultipleIndustryEntries() { final Industry industry = Industry.builder() - .airlineData(Arrays.asList( + .airline(Arrays.asList( AirlineData.builder().travelType("domestic").build(), AirlineData.builder().travelType("international").build())) - .accommodationData(Arrays.asList( + .accommodation(Arrays.asList( AccommodationData.builder().name("Grand Hotel").build(), AccommodationData.builder().name("Alpine Lodge").build())) .build(); final Industry result = serializer.fromJson(serializer.toJson(industry), Industry.class); - assertEquals(2, result.getAirlineData().size()); - assertEquals("domestic", result.getAirlineData().get(0).getTravelType()); - assertEquals("international", result.getAirlineData().get(1).getTravelType()); - assertEquals(2, result.getAccommodationData().size()); - assertEquals("Grand Hotel", result.getAccommodationData().get(0).getName()); - assertEquals("Alpine Lodge", result.getAccommodationData().get(1).getName()); + assertEquals(2, result.getAirline().size()); + assertEquals("domestic", result.getAirline().get(0).getTravelType()); + assertEquals("international", result.getAirline().get(1).getTravelType()); + assertEquals(2, result.getAccommodation().size()); + assertEquals("Grand Hotel", result.getAccommodation().get(0).getName()); + assertEquals("Alpine Lodge", result.getAccommodation().get(1).getName()); } @Test @@ -164,14 +164,14 @@ void shouldDeserializeIndustryFromTheSpecKeysAsArrays() { final Industry result = serializer.fromJson(json, Industry.class); - assertNotNull(result.getAirlineData()); - assertEquals(1, result.getAirlineData().size()); - assertEquals("international", result.getAirlineData().get(0).getTravelType()); - assertEquals("round_trip", result.getAirlineData().get(0).getTripType()); - assertNotNull(result.getAccommodationData()); - assertEquals(1, result.getAccommodationData().size()); - assertEquals("Grand Hotel", result.getAccommodationData().get(0).getName()); - assertEquals(2, result.getAccommodationData().get(0).getNumberOfRooms()); + assertNotNull(result.getAirline()); + assertEquals(1, result.getAirline().size()); + assertEquals("international", result.getAirline().get(0).getTravelType()); + assertEquals("round_trip", result.getAirline().get(0).getTripType()); + assertNotNull(result.getAccommodation()); + assertEquals(1, result.getAccommodation().size()); + assertEquals("Grand Hotel", result.getAccommodation().get(0).getName()); + assertEquals(2, result.getAccommodation().get(0).getNumberOfRooms()); } } diff --git a/src/test/java/com/checkout/payments/ProcessingSettingsSerializationTest.java b/src/test/java/com/checkout/payments/ProcessingSettingsSerializationTest.java index ab97bad5..bfb15c61 100644 --- a/src/test/java/com/checkout/payments/ProcessingSettingsSerializationTest.java +++ b/src/test/java/com/checkout/payments/ProcessingSettingsSerializationTest.java @@ -149,6 +149,7 @@ void shouldDeserializeAffiliateAndCardType() { } @Test + @SuppressWarnings("deprecation") // hubModelOriginationCountry is retained but not in any spec void shouldRoundTripSerialize() { final ProcessingSettings original = ProcessingSettings.builder() .panPreference(PanProcessedType.DPAN) @@ -223,4 +224,46 @@ void shouldDeserializeSchemeTransactionLinkId() { assertNotNull(settings); assertEquals("MTL-001", settings.getSchemeTransactionLinkId()); } + + // ------------------------------------------------------------------------ + // partner_customer_risk_data + // + // Declared on PaymentInterfacesProcessing, so hosted payments, payment links and payment + // sessions all read it. The field was missing from this class entirely, so a Java merchant + // on those endpoints could not send it at all. + // ------------------------------------------------------------------------ + + @Test + void shouldSerializePartnerCustomerRiskData() { + final String json = serializer.toJson(ProcessingSettings.builder() + .partnerCustomerRiskData(PartnerCustomerRiskData.builder() + .key("risk_score") + .value("42") + .build()) + .build()); + + assertTrue(json.contains("\"partner_customer_risk_data\""), json); + assertTrue(json.contains("\"key\":\"risk_score\""), json); + assertTrue(json.contains("\"value\":\"42\""), json); + } + + @Test + void shouldDeserializePartnerCustomerRiskData() { + final String json = "{\"partner_customer_risk_data\":" + + "{\"key\":\"risk_score\",\"value\":\"42\"}}"; + + final ProcessingSettings settings = serializer.fromJson(json, ProcessingSettings.class); + + assertNotNull(settings.getPartnerCustomerRiskData()); + assertEquals("risk_score", settings.getPartnerCustomerRiskData().getKey()); + assertEquals("42", settings.getPartnerCustomerRiskData().getValue()); + } + + @Test + void shouldOmitPartnerCustomerRiskDataWhenNotSet() { + final String json = serializer.toJson(ProcessingSettings.builder().orderId("ord_1").build()); + + assertTrue(!json.contains("partner_customer_risk_data"), json); + } + } diff --git a/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java b/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java index 14228bbb..1c695355 100644 --- a/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java +++ b/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java @@ -2,6 +2,7 @@ import com.checkout.GsonSerializer; import com.checkout.common.CountryCode; +import com.checkout.payments.AccommodationData; import com.checkout.payments.PassengerAddress; import org.junit.jupiter.api.Test; @@ -169,8 +170,9 @@ void shouldDeserializeAccommodationStateAndCountryAsFreeFormStrings() { + "\"room\":[{\"rate\":\"70\",\"number_of_nights_at_room_rate\":\"3\"}]" + "}"; - final PaymentContextsAccommodationData data = - serializer.fromJson(json, PaymentContextsAccommodationData.class); + // PaymentContextsProcessing.accommodationData now uses the shared AccommodationData, so + // that is the type this wire shape has to land in. + final AccommodationData data = serializer.fromJson(json, AccommodationData.class); assertEquals("The Sea View Hotel", data.getName()); assertEquals("FL", data.getState()); @@ -178,4 +180,40 @@ void shouldDeserializeAccommodationStateAndCountryAsFreeFormStrings() { assertEquals("Los Angeles", data.getCity()); assertEquals("3", data.getRoom().get(0).getNumberOfNightsAtRoomRate()); } + + /** + * PaymentContextsProcessing.accommodationData is typed with the shared AccommodationData. + * The old PaymentContextsAccommodationData is deprecated but retained, and still has to + * deserialize for anyone holding a reference to it. + */ + @Test + void shouldCarryAccommodationDataOnTheProcessingBlockUsingTheSharedType() { + final String json = "{\"accommodation_data\":[{" + + "\"name\":\"The Sea View Hotel\",\"state\":\"FL\",\"country\":\"USA\"," + + "\"room\":[{\"rate\":\"70\",\"number_of_nights_at_room_rate\":\"3\"}]" + + "}]}"; + + final PaymentContextsProcessing processing = + serializer.fromJson(json, PaymentContextsProcessing.class); + + assertEquals(1, processing.getAccommodationData().size()); + final AccommodationData shared = processing.getAccommodationData().get(0); + assertEquals("The Sea View Hotel", shared.getName()); + assertEquals("FL", shared.getState()); + assertEquals("USA", shared.getCountry()); + assertEquals("3", shared.getRoom().get(0).getNumberOfNightsAtRoomRate()); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedPaymentContextsAccommodationDataStillDeserializes() { + final String json = "{\"name\":\"Alpine Lodge\",\"state\":\"FL\",\"country\":\"USA\"}"; + + final PaymentContextsAccommodationData legacy = + serializer.fromJson(json, PaymentContextsAccommodationData.class); + + assertEquals("Alpine Lodge", legacy.getName()); + assertEquals("FL", legacy.getState()); + assertEquals("USA", legacy.getCountry()); + } } From 6d5e0197759a01bb83fbb5f4c5a98831ece0b6d7 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Fri, 25 Sep 2026 12:15:29 +0200 Subject: [PATCH 3/4] Fixed passenger specs mislignments + tests --- .../java/com/checkout/GsonSerializer.java | 91 ++++++++++++++++++- .../AirlineDataSerializationTest.java | 41 +++++++-- ...ymentContextsAirlineSerializationTest.java | 4 +- .../CustomDeserializerPolicyTest.java | 54 +++++++++-- 4 files changed, 171 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/checkout/GsonSerializer.java b/src/main/java/com/checkout/GsonSerializer.java index dc76edca..bc08eaa0 100644 --- a/src/main/java/com/checkout/GsonSerializer.java +++ b/src/main/java/com/checkout/GsonSerializer.java @@ -40,6 +40,11 @@ import com.google.gson.JsonSerializationContext; import com.google.gson.JsonSerializer; import com.google.gson.annotations.SerializedName; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; import com.google.gson.reflect.TypeToken; import com.google.gson.typeadapters.RuntimeTypeAdapterFactory; import lombok.Getter; @@ -108,13 +113,15 @@ public final class GsonSerializer implements Serializer { .registerTypeAdapter(LocalDate.class, (JsonSerializer) (LocalDate date, Type typeOfSrc, JsonSerializationContext context) -> new JsonPrimitive(date.format(DateTimeFormatter.ISO_LOCAL_DATE))) .registerTypeAdapter(LocalDate.class, getLocalDateJsonDeserializer()) - // processing.airline_data[].passenger is oneOf[array, object]: PayPal returns a - // single object where the array shape is declared. Read both, always write an array. + // processing.airline_data[].passenger arrives as an array or as a single object. + // These two read both shapes into a List. // // Bound by element type, so the second registration also covers PaymentSetupAirline - // .passengers, which the spec declares array-only. Accepting a bare object there is - // wider than the spec grants but cannot lose data, and TypeToken cannot distinguish - // the two call sites. Deliberate; revisit only if a wrapper type is introduced. + // .passengers, which the spec declares array-only. Accepting a bare object there on + // READ is wider than the spec grants but cannot lose data. Writing is handled by + // singleOrArrayPassengerFactory below, which is scoped to the two airline types so + // that setups .passengers keeps emitting an array (the API rejects an object there + // with industry.airline[0].passengers_property_invalid). .registerTypeAdapter( new TypeToken>() { }.getType(), @@ -123,6 +130,7 @@ public final class GsonSerializer implements Serializer { new TypeToken>() { }.getType(), singleOrArrayDeserializer(com.checkout.payments.contexts.PaymentContextsPassenger.class)) + .registerTypeAdapterFactory(singleOrArrayPassengerFactory()) // Payments - AbstractSource (polymorphic deserialization) .registerTypeAdapterFactory( RuntimeTypeAdapterFactory.of( @@ -489,6 +497,79 @@ private static JsonDeserializer getInstantJsonDeserializer() { * @param the list element type * @return a deserializer that accepts a single object or an array */ + /** + * Writes {@code processing.airline_data[].passenger} as a single object when there is exactly + * one passenger and as an array only when there are several. + * + *

The live API does not match the specification in either direction. Verified against the + * sandbox on 2026-09-25 with a complete {@code airline_data} block: + * + *

+     * surface                  passenger: object   passenger: array
+     * POST /payments           201                 201
+     * POST /hosted-payments    accepted            422 processing_airline_data_0_passenger_invalid
+     * POST /payment-links      accepted            422 processing_airline_data_0_passenger_invalid
+     * POST /payment-contexts   201                 422 passenger_required
+     * 
+ * + *

A single object is accepted on every request surface; an array only on + * {@code POST /payments}. {@link com.checkout.payments.ProcessingSettings} is shared by + * {@code POST /payments}, hosted payments and payment links, so always emitting an array + * would break the latter two. + * + *

An empty array and an explicit null are both rejected with + * {@code processing_airline_data_0_passenger_invalid}, so an empty list drops the member + * entirely. + * + *

Scoped to the two airline types by raw class, so {@code PaymentSetupAirline.passengers} + * is untouched: the API rejects an object there with + * {@code industry.airline[0].passengers_property_invalid}. + * + * @return a factory that fixes up the passenger cardinality on write + */ + private static TypeAdapterFactory singleOrArrayPassengerFactory() { + return new TypeAdapterFactory() { + @Override + public TypeAdapter create(final Gson gson, final TypeToken type) { + final Class raw = type.getRawType(); + if (!com.checkout.payments.AirlineData.class.equals(raw) + && !com.checkout.payments.contexts.PaymentContextsAirlineData.class.equals(raw)) { + return null; + } + + // getDelegateAdapter returns the adapter Gson would otherwise use, so the + // reflective serializer still writes every other field and this cannot recurse. + final TypeAdapter delegate = gson.getDelegateAdapter(this, type); + final TypeAdapter elements = gson.getAdapter(JsonElement.class); + + return new TypeAdapter() { + @Override + public void write(final JsonWriter out, final T value) throws IOException { + final JsonElement tree = delegate.toJsonTree(value); + if (tree.isJsonObject()) { + final JsonObject object = tree.getAsJsonObject(); + final JsonElement passenger = object.get("passenger"); + if (passenger != null && passenger.isJsonArray()) { + final JsonArray array = passenger.getAsJsonArray(); + if (array.size() == 0) { + object.remove("passenger"); + } else if (array.size() == 1) { + object.add("passenger", array.get(0)); + } + } + } + elements.write(out, tree); + } + + @Override + public T read(final JsonReader in) throws IOException { + return delegate.read(in); + } + }; + } + }; + } + private static JsonDeserializer> singleOrArrayDeserializer(final Class elementType) { return (json, typeOfT, context) -> { if (json == null || json.isJsonNull()) { diff --git a/src/test/java/com/checkout/payments/AirlineDataSerializationTest.java b/src/test/java/com/checkout/payments/AirlineDataSerializationTest.java index 2b94a1e3..49d23e4f 100644 --- a/src/test/java/com/checkout/payments/AirlineDataSerializationTest.java +++ b/src/test/java/com/checkout/payments/AirlineDataSerializationTest.java @@ -153,12 +153,14 @@ void shouldDeserializeMultiplePassengers() { } /** - * Only a deserializer is registered for the single-or-array shape, never a serializer, so - * writing always goes through Gson's reflective adapter and emits an array. If a serializer - * were ever added, every request carrying airline data would change shape and this fails. + * Pins the outbound cardinality against the live API: an object is accepted on every request + * surface, an array only on POST /payments. Hosted payments, payment links and payment + * contexts all reject the array form, and ProcessingSettings is shared with hosted payments + * and payment links, so "always an array" would break them. See + * GsonSerializer.singleOrArrayPassengerFactory for the sandbox-verified matrix. */ @Test - void shouldAlwaysSerializePassengerAsAnArray() { + void shouldSerializeASinglePassengerAsAnObject() { final AirlineData airline = AirlineData.builder() .passenger(Collections.singletonList( Passenger.builder().firstName("John").lastName("White").build())) @@ -166,8 +168,35 @@ void shouldAlwaysSerializePassengerAsAnArray() { final String json = serializer.toJson(airline); - assertTrue(json.contains("\"passenger\":[{"), json); - assertFalse(json.contains("\"passenger\":{"), json); + assertTrue(json.contains("\"passenger\":{"), json); + assertFalse(json.contains("\"passenger\":["), json); + } + + @Test + void shouldSerializeSeveralPassengersAsAnArray() { + final AirlineData airline = AirlineData.builder() + .passenger(Arrays.asList( + Passenger.builder().firstName("John").build(), + Passenger.builder().firstName("Jane").build())) + .build(); + + assertTrue(serializer.toJson(airline).contains("\"passenger\":[{")); + } + + /** + * An empty array and an explicit null are both rejected with + * processing_airline_data_0_passenger_invalid, so the member has to be absent. + */ + @Test + void shouldOmitPassengerWhenThereAreNone() { + assertFalse(serializer.toJson(AirlineData.builder() + .ticket(Ticket.builder().number("045").build()) + .passenger(Collections.emptyList()) + .build()).contains("passenger")); + + assertFalse(serializer.toJson(AirlineData.builder() + .ticket(Ticket.builder().number("045").build()) + .build()).contains("passenger")); } @Test diff --git a/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java b/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java index 1c695355..78166a1f 100644 --- a/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java +++ b/src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java @@ -49,7 +49,9 @@ void shouldSerializeAirlineTicketAsAnObjectAndPassengerAsAnArray() { assertTrue(json.contains("\"ticket\":{"), json); assertFalse(json.contains("\"ticket\":["), json); - assertTrue(json.contains("\"passenger\":[{"), json); + // POST /payment-contexts rejects the array form with passenger_required, so one + // passenger serializes as an object. + assertTrue(json.contains("\"passenger\":{"), json); assertTrue(json.contains("\"class_of_travelling\":\"J\""), json); assertTrue(json.contains("\"stop_over_code\":\"x\""), json); assertTrue(json.contains("\"flight_number\":\"101\""), json); diff --git a/src/test/java/com/checkout/serialization/CustomDeserializerPolicyTest.java b/src/test/java/com/checkout/serialization/CustomDeserializerPolicyTest.java index 16c35e9b..36ca3011 100644 --- a/src/test/java/com/checkout/serialization/CustomDeserializerPolicyTest.java +++ b/src/test/java/com/checkout/serialization/CustomDeserializerPolicyTest.java @@ -157,19 +157,59 @@ void singleOrArrayDeserializer_honorsNamingPolicyForAnArray() { } /** - * Only a deserializer is registered, so writing goes through the reflective adapter and - * always emits an array. Registering a serializer would silently change every outbound - * request that carries airline data. + * singleOrArrayPassengerFactory wraps the reflective adapter on the write side and applies + * the cardinality the live API requires: an object for one passenger, an array for several. + * It is scoped to the two airline types, so PaymentSetupAirline.passengers is untouched. */ @Test - void singleOrArrayDeserializer_isReadOnlySoWritesStayAnArray() { - final String json = serializer.toJson(AirlineData.builder() + void singleOrArrayPassengerFactory_writesOneAsObjectAndSeveralAsArray() { + final String one = serializer.toJson(AirlineData.builder() .passenger(Collections.singletonList( Passenger.builder().firstName("John").build())) .build()); - assertTrue(json.contains("\"passenger\":[{"), json); - assertTrue(!json.contains("\"passenger\":{"), json); + assertTrue(one.contains("\"passenger\":{"), one); + assertTrue(!one.contains("\"passenger\":["), one); + + final String many = serializer.toJson(AirlineData.builder() + .passenger(java.util.Arrays.asList( + Passenger.builder().firstName("John").build(), + Passenger.builder().firstName("Jane").build())) + .build()); + + assertTrue(many.contains("\"passenger\":[{"), many); + } + + /** + * The factory must not reach PaymentSetupAirline.passengers: the API rejects an object there + * with industry.airline[0].passengers_property_invalid. + */ + @Test + void singleOrArrayPassengerFactory_leavesPaymentSetupPassengersAsAnArray() { + final String json = serializer.toJson( + com.checkout.handlepaymentsandpayouts.setups.entities.industry.AirlineData.builder() + .passengers(Collections.singletonList( + com.checkout.payments.contexts.PaymentContextsPassenger.builder() + .firstName("John").build())) + .build()); + + assertTrue(json.contains("\"passengers\":[{"), json); + } + + @Test + void singleOrArrayPassengerFactory_roundTripsASinglePassenger() { + final AirlineData original = AirlineData.builder() + .passenger(Collections.singletonList(Passenger.builder() + .firstName("John") + .dateOfBirth(LocalDate.of(1990, 5, 26)) + .build())) + .build(); + + final AirlineData result = serializer.fromJson(serializer.toJson(original), AirlineData.class); + + assertEquals(1, result.getPassenger().size()); + assertEquals("John", result.getPassenger().get(0).getFirstName()); + assertEquals(LocalDate.of(1990, 5, 26), result.getPassenger().get(0).getDateOfBirth()); } @Test From 9a64bda12b07643008dca0b835ae50931f8c62ca Mon Sep 17 00:00:00 2001 From: david ruiz Date: Mon, 28 Sep 2026 15:16:21 +0200 Subject: [PATCH 4/4] senderInformation comment --- .../com/checkout/payments/ProcessingSettings.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/checkout/payments/ProcessingSettings.java b/src/main/java/com/checkout/payments/ProcessingSettings.java index 156ba174..e7a91207 100644 --- a/src/main/java/com/checkout/payments/ProcessingSettings.java +++ b/src/main/java/com/checkout/payments/ProcessingSettings.java @@ -302,6 +302,17 @@ public final class ProcessingSettings { /** * Sender information. Used for Previous API only. * [Optional] + * + *

Not in the current specification. The property appears under neither + * {@code senderInformation} nor {@code sender_information} in any spec available to this + * workspace, including the live API reference, and no processing schema declares a sender + * property of any kind. Deprecated in practice; the current API carries sender details in the + * top level {@code sender} object on the payment request instead. + * + *

Left exactly as it was on purpose. Gson's global + * {@code LOWER_CASE_WITH_UNDERSCORES} policy sends this as {@code sender_information}. There + * is no evidence establishing which key, if either, the gateway reads, so nothing here + * overrides that. Do not add a {@code @SerializedName} without a live confirmation. */ private SenderInformation senderInformation;