Skip to content

Commit 45c21df

Browse files
Feature/INT-1702 - Airline and accommodation sub-tree model alignment (#676)
* Airline and accommodation sub-tree model alignment * JDoc + model adjustments + tests * Fixed passenger specs mislignments + tests * senderInformation comment * Types fix * Accounts test fix
1 parent 51accbb commit 45c21df

30 files changed

Lines changed: 1550 additions & 87 deletions

‎src/main/java/com/checkout/GsonSerializer.java‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
import com.google.gson.JsonSerializationContext;
4141
import com.google.gson.JsonSerializer;
4242
import com.google.gson.annotations.SerializedName;
43+
import com.google.gson.TypeAdapter;
44+
import com.google.gson.TypeAdapterFactory;
45+
import com.google.gson.stream.JsonReader;
46+
import com.google.gson.stream.JsonWriter;
47+
import java.io.IOException;
4348
import com.google.gson.reflect.TypeToken;
4449
import com.google.gson.typeadapters.RuntimeTypeAdapterFactory;
4550
import lombok.Getter;
@@ -108,6 +113,24 @@ public final class GsonSerializer implements Serializer {
108113
.registerTypeAdapter(LocalDate.class, (JsonSerializer<LocalDate>) (LocalDate date, Type typeOfSrc, JsonSerializationContext context) ->
109114
new JsonPrimitive(date.format(DateTimeFormatter.ISO_LOCAL_DATE)))
110115
.registerTypeAdapter(LocalDate.class, getLocalDateJsonDeserializer())
116+
// processing.airline_data[].passenger arrives as an array or as a single object.
117+
// These two read both shapes into a List.
118+
//
119+
// Bound by element type, so the second registration also covers PaymentSetupAirline
120+
// .passengers, which the spec declares array-only. Accepting a bare object there on
121+
// READ is wider than the spec grants but cannot lose data. Writing is handled by
122+
// singleOrArrayPassengerFactory below, which is scoped to the two airline types so
123+
// that setups .passengers keeps emitting an array (the API rejects an object there
124+
// with industry.airline[0].passengers_property_invalid).
125+
.registerTypeAdapter(
126+
new TypeToken<List<com.checkout.payments.Passenger>>() {
127+
}.getType(),
128+
singleOrArrayDeserializer(com.checkout.payments.Passenger.class))
129+
.registerTypeAdapter(
130+
new TypeToken<List<com.checkout.payments.contexts.PaymentContextsPassenger>>() {
131+
}.getType(),
132+
singleOrArrayDeserializer(com.checkout.payments.contexts.PaymentContextsPassenger.class))
133+
.registerTypeAdapterFactory(singleOrArrayPassengerFactory())
111134
// Payments - AbstractSource (polymorphic deserialization)
112135
.registerTypeAdapterFactory(
113136
RuntimeTypeAdapterFactory.of(
@@ -454,6 +477,116 @@ private static JsonDeserializer<Instant> getInstantJsonDeserializer() {
454477
};
455478
}
456479

480+
/**
481+
* Reads a property the specification declares as {@code oneOf[array, object]} into a list,
482+
* accepting either shape on the wire and normalizing a bare object into a single-element list.
483+
* <p>
484+
* The first property to need this is {@code processing.airline_data[].passenger}.
485+
* {@code AirlineData} declares it as an array, while
486+
* {@code PaymentInterfacesProcessingAirlineData} declares it as {@code oneOf[array, object]}
487+
* with the note "PayPal requires a single object". Both branches resolve to the same object,
488+
* so normalizing to a list loses nothing.
489+
* <p>
490+
* Only a deserializer is registered, never a serializer, so writing still goes through Gson's
491+
* reflective adapter and always emits an array. That is the only valid outbound shape for
492+
* {@code AirlineData}. Element deserialization is delegated to the supplied context, so the
493+
* global {@code LOWER_CASE_WITH_UNDERSCORES} naming policy and the {@code LocalDate} adapter
494+
* still apply; this deserializer never maps property names itself.
495+
*
496+
* @param elementType the list element type
497+
* @param <T> the list element type
498+
* @return a deserializer that accepts a single object or an array
499+
*/
500+
/**
501+
* Writes {@code processing.airline_data[].passenger} as a single object when there is exactly
502+
* one passenger and as an array only when there are several.
503+
*
504+
* <p>The live API does not match the specification in either direction. Verified against the
505+
* sandbox on 2026-09-25 with a complete {@code airline_data} block:
506+
*
507+
* <pre>
508+
* surface passenger: object passenger: array
509+
* POST /payments 201 201
510+
* POST /hosted-payments accepted 422 processing_airline_data_0_passenger_invalid
511+
* POST /payment-links accepted 422 processing_airline_data_0_passenger_invalid
512+
* POST /payment-contexts 201 422 passenger_required
513+
* </pre>
514+
*
515+
* <p>A single object is accepted on every request surface; an array only on
516+
* {@code POST /payments}. {@link com.checkout.payments.ProcessingSettings} is shared by
517+
* {@code POST /payments}, hosted payments and payment links, so always emitting an array
518+
* would break the latter two.
519+
*
520+
* <p>An empty array and an explicit null are both rejected with
521+
* {@code processing_airline_data_0_passenger_invalid}, so an empty list drops the member
522+
* entirely.
523+
*
524+
* <p>Scoped to the two airline types by raw class, so {@code PaymentSetupAirline.passengers}
525+
* is untouched: the API rejects an object there with
526+
* {@code industry.airline[0].passengers_property_invalid}.
527+
*
528+
* @return a factory that fixes up the passenger cardinality on write
529+
*/
530+
private static TypeAdapterFactory singleOrArrayPassengerFactory() {
531+
return new TypeAdapterFactory() {
532+
@Override
533+
public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> type) {
534+
final Class<?> raw = type.getRawType();
535+
if (!com.checkout.payments.AirlineData.class.equals(raw)
536+
&& !com.checkout.payments.contexts.PaymentContextsAirlineData.class.equals(raw)) {
537+
return null;
538+
}
539+
540+
// getDelegateAdapter returns the adapter Gson would otherwise use, so the
541+
// reflective serializer still writes every other field and this cannot recurse.
542+
final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
543+
final TypeAdapter<JsonElement> elements = gson.getAdapter(JsonElement.class);
544+
545+
return new TypeAdapter<T>() {
546+
@Override
547+
public void write(final JsonWriter out, final T value) throws IOException {
548+
final JsonElement tree = delegate.toJsonTree(value);
549+
if (tree.isJsonObject()) {
550+
final JsonObject object = tree.getAsJsonObject();
551+
final JsonElement passenger = object.get("passenger");
552+
if (passenger != null && passenger.isJsonArray()) {
553+
final JsonArray array = passenger.getAsJsonArray();
554+
if (array.size() == 0) {
555+
object.remove("passenger");
556+
} else if (array.size() == 1) {
557+
object.add("passenger", array.get(0));
558+
}
559+
}
560+
}
561+
elements.write(out, tree);
562+
}
563+
564+
@Override
565+
public T read(final JsonReader in) throws IOException {
566+
return delegate.read(in);
567+
}
568+
};
569+
}
570+
};
571+
}
572+
573+
private static <T> JsonDeserializer<List<T>> singleOrArrayDeserializer(final Class<T> elementType) {
574+
return (json, typeOfT, context) -> {
575+
if (json == null || json.isJsonNull()) {
576+
return null;
577+
}
578+
final List<T> values = new ArrayList<>();
579+
if (json.isJsonArray()) {
580+
for (final JsonElement element : json.getAsJsonArray()) {
581+
values.add(context.deserialize(element, elementType));
582+
}
583+
} else {
584+
values.add(context.deserialize(json, elementType));
585+
}
586+
return values;
587+
};
588+
}
589+
457590
private static JsonDeserializer<LocalDate> getLocalDateJsonDeserializer() {
458591
return (json, typeOfT, context) -> {
459592
String dateString = json.getAsString();
Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.checkout.handlepaymentsandpayouts.setups.entities.industry;
22

3-
import com.google.gson.annotations.SerializedName;
43
import lombok.AllArgsConstructor;
54
import lombok.Builder;
65
import lombok.Data;
@@ -9,7 +8,7 @@
98
import java.util.List;
109

1110
/**
12-
* Industry-specific payment setup information
11+
* Industry-specific information.
1312
*/
1413
@Data
1514
@Builder
@@ -18,14 +17,21 @@
1817
public final class Industry {
1918

2019
/**
21-
* Airline industry-specific data for flight bookings and related payments
20+
* Airline industry-specific data for flight bookings and related payments.
21+
* [Optional]
22+
* <p>
23+
* Maps the specification property {@code airline}, which is an array. This was previously a
24+
* single object named {@code airlineData}, so it needed an explicit serialized-name override
25+
* to reach the right key at all, and it serialized as an object where the API expects an
26+
* array, meaning the value never reached the gateway.
2227
*/
23-
@SerializedName("airline")
24-
private AirlineData airlineData;
28+
private List<AirlineData> airline;
2529

2630
/**
27-
* Accommodation industry-specific data for hotel and cruise bookings and related payments
31+
* Accommodation industry-specific data for hotel and cruise bookings and related payments.
32+
* [Optional]
33+
* <p>
34+
* Maps the specification property {@code accommodation}.
2835
*/
29-
@SerializedName("accommodation")
30-
private List<AccommodationData> accommodationData;
36+
private List<AccommodationData> accommodation;
3137
}

‎src/main/java/com/checkout/payments/AccommodationData.java‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.checkout.payments;
22

33
import com.checkout.common.Address;
4-
import com.checkout.common.CountryCode;
54
import com.checkout.common.Phone;
65
import lombok.AllArgsConstructor;
76
import lombok.Builder;
@@ -11,6 +10,9 @@
1110
import java.time.LocalDate;
1211
import java.util.List;
1312

13+
/**
14+
* Contains information about the accommodation booked by the customer.
15+
*/
1416
@Data
1517
@Builder
1618
@NoArgsConstructor
@@ -44,8 +46,12 @@ public final class AccommodationData {
4446
private LocalDate checkOutDate;
4547

4648
/**
47-
* The address of the accommodation property.
49+
* The address details of the accommodation.
4850
* [Optional]
51+
* <p>
52+
* The specification defines only {@code address_line1} and {@code zip} on this object. The
53+
* wider {@link Address} type is reused for consistency with the rest of the SDK; the
54+
* remaining members are not read by the API on this property.
4955
*/
5056
private Address address;
5157

@@ -56,10 +62,13 @@ public final class AccommodationData {
5662
private String state;
5763

5864
/**
59-
* The country where the property is located, as an ISO 3166-1 alpha-2 code.
65+
* The ISO country code of the address.
6066
* [Optional]
67+
* <p>
68+
* A free-form string rather than an ISO 3166-1 alpha-2 enum: the specification's example is
69+
* the three-letter code {@code USA}, which no alpha-2 enum can represent. Mapping as string.
6170
*/
62-
private CountryCode country;
71+
private String country;
6372

6473
/**
6574
* The city where the property is located.

‎src/main/java/com/checkout/payments/AccommodationGuest.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
import java.time.LocalDate;
99

10+
/**
11+
* Contains information about a guest staying at the accommodation.
12+
*/
1013
@Data
1114
@Builder
1215
@NoArgsConstructor

‎src/main/java/com/checkout/payments/AccommodationRoom.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import lombok.Data;
66
import lombok.NoArgsConstructor;
77

8+
/**
9+
* Contains information about a room booked by the customer.
10+
*/
811
@Data
912
@Builder
1013
@NoArgsConstructor

‎src/main/java/com/checkout/payments/AirlineData.java‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,35 @@
77

88
import java.util.List;
99

10+
/**
11+
* Contains information about the airline ticket and flights booked by the customer.
12+
*/
1013
@Data
1114
@Builder
1215
@NoArgsConstructor
1316
@AllArgsConstructor
1417
public final class AirlineData {
1518

19+
/**
20+
* Contains information about the airline ticket.
21+
* [Optional]
22+
*/
1623
private Ticket ticket;
1724

25+
/**
26+
* Contains information about the passenger(s) on the flight.
27+
* [Optional]
28+
* <p>
29+
* The API returns this as an array. Some payment methods, PayPal among them, send a single
30+
* object instead, which the specification allows on the payment sessions, hosted payments
31+
* and payment links interfaces. Both shapes deserialize here; a single object becomes a
32+
* one-element list. Serialization always emits an array.
33+
*/
1834
private List<Passenger> passenger;
1935

36+
/**
37+
* Contains information about the flight leg(s) booked by the customer.
38+
* [Optional]
39+
*/
2040
private List<FlightLegDetails> flightLegDetails;
2141
}

‎src/main/java/com/checkout/payments/FlightLegDetails.java‎

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,87 @@
55
import lombok.Data;
66
import lombok.NoArgsConstructor;
77

8+
import java.time.LocalDate;
9+
10+
/**
11+
* Contains information about a flight leg booked by the customer.
12+
*/
813
@Data
914
@Builder
1015
@NoArgsConstructor
1116
@AllArgsConstructor
1217
public final class FlightLegDetails {
1318

14-
private Long flightNumber;
19+
/**
20+
* The flight identifier.
21+
* [Optional]
22+
*/
23+
private String flightNumber;
1524

25+
/**
26+
* The IATA 2-letter accounting code (PAX) that identifies the carrier.
27+
* This field is required if the airline data includes leg details.
28+
* [Optional]
29+
*/
1630
private String carrierCode;
1731

18-
private String serviceClass;
32+
/**
33+
* A one-letter travel class identifier. The following are common:
34+
* F = First class, J = Business class, Y = Economy class, W = Premium economy.
35+
* [Optional]
36+
*/
37+
private String classOfTravelling;
1938

20-
private String departureDate;
39+
/**
40+
* The IATA three-letter airport code of the departure airport.
41+
* This field is required if the airline data includes leg details.
42+
* [Optional]
43+
*/
44+
private String departureAirport;
2145

22-
private String departureTime;
46+
/**
47+
* The date of the scheduled take off.
48+
* [Optional]
49+
* Format: yyyy-MM-dd
50+
*/
51+
private LocalDate departureDate;
2352

24-
private String departureAirport;
53+
/**
54+
* The time of the scheduled take off.
55+
* [Optional]
56+
*/
57+
private String departureTime;
2558

59+
/**
60+
* The IATA 3-letter airport code of the destination airport.
61+
* This field is required if the airline data includes leg details.
62+
* [Optional]
63+
*/
2664
private String arrivalAirport;
2765

28-
private String stopoverCode;
66+
/**
67+
* A one-letter code that indicates whether the passenger is entitled to make a stopover.
68+
* Can be a space, O if the passenger is entitled to make a stopover, or X if they are not.
69+
* [Optional]
70+
*/
71+
private String stopOverCode;
2972

73+
/**
74+
* The fare basis code, alphanumeric.
75+
* [Optional]
76+
*/
3077
private String fareBasisCode;
3178

79+
/**
80+
* Not in the current spec, will be removed in a future version.
81+
* Serializes as {@code service_class}, which the API does not define, so the value is
82+
* discarded by the gateway. Use {@link #getClassOfTravelling()} instead, which maps the
83+
* spec property {@code class_of_travelling}.
84+
*
85+
* @deprecated Not defined by the API, the gateway discards it. Use
86+
* {@code classOfTravelling}, which maps {@code class_of_travelling}.
87+
*/
88+
@Deprecated
89+
private String serviceClass;
90+
3291
}

0 commit comments

Comments
 (0)