4040import com .google .gson .JsonSerializationContext ;
4141import com .google .gson .JsonSerializer ;
4242import 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 ;
4348import com .google .gson .reflect .TypeToken ;
4449import com .google .gson .typeadapters .RuntimeTypeAdapterFactory ;
4550import lombok .Getter ;
@@ -108,13 +113,15 @@ 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 ())
111- // processing.airline_data[].passenger is oneOf[array, object]: PayPal returns a
112- // single object where the array shape is declared. Read both, always write an array .
116+ // processing.airline_data[].passenger arrives as an array or as a single object.
117+ // These two read both shapes into a List .
113118 //
114119 // Bound by element type, so the second registration also covers PaymentSetupAirline
115- // .passengers, which the spec declares array-only. Accepting a bare object there is
116- // wider than the spec grants but cannot lose data, and TypeToken cannot distinguish
117- // the two call sites. Deliberate; revisit only if a wrapper type is introduced.
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).
118125 .registerTypeAdapter (
119126 new TypeToken <List <com .checkout .payments .Passenger >>() {
120127 }.getType (),
@@ -123,6 +130,7 @@ public final class GsonSerializer implements Serializer {
123130 new TypeToken <List <com .checkout .payments .contexts .PaymentContextsPassenger >>() {
124131 }.getType (),
125132 singleOrArrayDeserializer (com .checkout .payments .contexts .PaymentContextsPassenger .class ))
133+ .registerTypeAdapterFactory (singleOrArrayPassengerFactory ())
126134 // Payments - AbstractSource (polymorphic deserialization)
127135 .registerTypeAdapterFactory (
128136 RuntimeTypeAdapterFactory .of (
@@ -489,6 +497,79 @@ private static JsonDeserializer<Instant> getInstantJsonDeserializer() {
489497 * @param <T> the list element type
490498 * @return a deserializer that accepts a single object or an array
491499 */
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+
492573 private static <T > JsonDeserializer <List <T >> singleOrArrayDeserializer (final Class <T > elementType ) {
493574 return (json , typeOfT , context ) -> {
494575 if (json == null || json .isJsonNull ()) {
0 commit comments