From 67bc9b4c0a5bbae44dad709eeb6176900d717b68 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Mon, 28 Sep 2026 11:55:32 +0200 Subject: [PATCH 1/4] Airline and accommodation sub-tree model alignment --- checkout_sdk/payments/contexts/contexts.py | 131 +++++++++-- checkout_sdk/payments/payments.py | 189 ++++++++++++++++ .../airline_data_serialization_test.py | 209 ++++++++++++++++++ ...ent_contexts_airline_serialization_test.py | 175 +++++++++++++++ 4 files changed, 691 insertions(+), 13 deletions(-) create mode 100644 tests/payments/airline_data_serialization_test.py create mode 100644 tests/payments/contexts/payment_contexts_airline_serialization_test.py diff --git a/checkout_sdk/payments/contexts/contexts.py b/checkout_sdk/payments/contexts/contexts.py index 551b420e..2bd3250a 100644 --- a/checkout_sdk/payments/contexts/contexts.py +++ b/checkout_sdk/payments/contexts/contexts.py @@ -1,63 +1,168 @@ from deprecated import deprecated -from checkout_sdk.common.common import Address, CustomerRequest, AccountHolder +from checkout_sdk.common.common import CustomerRequest, AccountHolder from checkout_sdk.common.enums import Currency, PaymentSourceType from checkout_sdk.payments.payments import PaymentRequestSource, PaymentType, ShippingDetails, BillingPlan, \ - ShippingPreference, UserAction + ShippingPreference, UserAction, PassengerAddress class PaymentContextsPartnerCustomerRiskData: + """A key-and-value pair with merchant-specific data for the transaction.""" + # The key for the pair. + # [Optional] key: str + # The value for the pair. + # [Optional] value: str class PaymentContextsTicket: + """Contains information about the airline ticket.""" + # The ticket's unique identifier. + # [Optional] number: str - issue_date: str # Format: yyyy-MM-dd + # Date the airline ticket was issued. + # [Optional] + # format: date (YYYY-MM-DD) + issue_date: str + # Carrier code of the ticket issuer. + # [Optional] issuing_carrier_code: str + # C = Car rental reservation, A = Airline flight reservation, B = Both car rental and + # airline flight reservations included, N = Unknown. + # [Optional] travel_package_indicator: str + # The name of the travel agency. + # [Optional] travel_agency_name: str + # The unique identifier from IATA or ARC for the travel agency that issues the ticket. + # [Optional] travel_agency_code: str class PaymentContextsPassenger: + """Contains information about a passenger on the flight.""" + # The passenger's first name. + # [Optional] first_name: str + # The passenger's last name. + # [Optional] last_name: str - date_of_birth: str # Format: yyyy-MM-dd - address: Address + # The passenger's date of birth. + # [Optional] + # format: date (YYYY-MM-DD) + date_of_birth: str + # Contains information about the passenger's address. + # [Optional] + # + # The spec defines exactly one property on this object, country. This was the wider common + # Address, whose other members the API does not read here. It now shares + # payments.PassengerAddress so one spec shape maps to one class. + address: PassengerAddress class PaymentContextsFlightLegDetails: + """Contains information about a flight leg booked by the customer.""" + # The flight identifier. + # [Optional] flight_number: str + # The IATA 2-letter accounting code (PAX) that identifies the carrier. + # [Optional] carrier_code: str + # A one-letter travel class identifier. The following are common: F = First class, + # J = Business class, Y = Economy class, W = Premium economy. + # [Optional] class_of_travelling: str + # The IATA three-letter airport code of the departure airport. + # [Optional] departure_airport: str - departure_date: str # Format: yyyy-MM-dd + # The date of the scheduled take off. + # [Optional] + # format: date (YYYY-MM-DD) + departure_date: str + # The time of the scheduled take off. + # [Optional] departure_time: str + # The IATA 3-letter airport code of the destination airport. + # [Optional] arrival_airport: str + # 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] stop_over_code: str + # The fare basis code, alphanumeric. + # [Optional] fare_basis_code: str class PaymentContextsAirlineData: - ticket: list # payment.contexts.PaymentContextsTicket - passenger: list # payment.contexts.PaymentContextsPassenger - flight_leg_details: list # payment.contexts.PaymentContextsFlightLegDetails + """Contains information about the airline ticket and flights booked by the customer.""" + # Contains information about the airline ticket. + # [Optional] + # + # The spec declares this as a single object. It was annotated as a list, so the SDK sent an + # array, a shape the API does not accept. + ticket: PaymentContextsTicket + # Contains information about the passenger(s) on the flight. + # [Optional] + # + # Assign a single PaymentContextsPassenger, not a one-element list. POST /payment-contexts + # rejects the list form with 422 passenger_required and accepts a single object, verified + # against the sandbox on 2026-09-25. See payments.AirlineData.passenger for the full + # cross-surface matrix. + passenger: PaymentContextsPassenger + # Contains information about the flight leg(s) booked by the customer. + # [Optional] + flight_leg_details: list # PaymentContextsFlightLegDetails class PaymentContextsProcessing: + """Settings that control how the payment context is processed.""" + # The plan details for a recurring payment with PayPal. Required when payment_type is + # recurring. + # [Optional] plan: BillingPlan + # The total freight or shipping and handling charges for the transaction. + # [Optional] shipping_amount: int + # Invoice ID number. + # [Optional] invoice_id: str + # The label that overrides the business name in the PayPal account on the PayPal pages. + # [Optional] brand_name: str + # The language and region of the customer in ISO 639-2 language code; the value consists of + # language-country. + # [Optional] locale: str + # Shipping preference. + # [Optional] + # One of: no_shipping, set_provided_address, get_from_file shipping_preference: ShippingPreference + # Property required by PayPal to have an appropriate payment flow. + # [Optional] + # One of: pay_now, continue user_action: UserAction - partner_customer_risk_data: list # payment.contexts.PaymentContextsPartnerCustomerRiskData - airline_data: list # payment.contexts.PaymentContextsAirlineData - accommodation_data: list # AccommodationData - custom_payment_method_ids: list # list of str + # Key-and-value pairs with merchant-specific data for the transaction. + # [Optional] + partner_customer_risk_data: list # PaymentContextsPartnerCustomerRiskData + # Contains information about the airline ticket and flights booked by the customer. + # [Optional] + airline_data: list # PaymentContextsAirlineData + # Contains information about the accommodation booked by the customer. Uses the shared + # payments.AccommodationData: payment contexts, POST /payments and the GET /payments/{id} + # response all resolve accommodation_data to the same specification schema. + # [Optional] + accommodation_data: list # payments.AccommodationData + # Promo codes. They define which of the configured payment options within a payment + # category (pay_later, pay_over_time, and so on) are shown for this purchase. + # [Optional] + custom_payment_method_ids: list # str + # The discount amount the merchant applied to the transaction. + # [Optional] discount_amount: int + # The total tax amount for the transaction, in the minor currency unit. + # [Optional] tax_amount: int diff --git a/checkout_sdk/payments/payments.py b/checkout_sdk/payments/payments.py index f97e04a0..97865969 100644 --- a/checkout_sdk/payments/payments.py +++ b/checkout_sdk/payments/payments.py @@ -535,34 +535,223 @@ class PartnerCustomerRiskData: value: str +class Ticket: + """Contains information about the airline ticket.""" + # The ticket's unique identifier. + # [Optional] + number: str + # Date the airline ticket was issued. + # [Optional] + # format: date (YYYY-MM-DD) + issue_date: str + # Carrier code of the ticket issuer. + # [Optional] + issuing_carrier_code: str + # C = Car rental reservation, A = Airline flight reservation, B = Both car rental and + # airline flight reservations included, N = Unknown. Free-form string in the spec, not a + # typed enum. + # [Optional] + travel_package_indicator: str + # The name of the travel agency. + # [Optional] + travel_agency_name: str + # The unique identifier from IATA or ARC for the travel agency that issues the ticket. + # [Optional] + travel_agency_code: str + + +class PassengerAddress: + """Contains information about a passenger's address.""" + # The two-letter ISO country code of the passenger's country of residence. + # [Optional] + country: str + + +class Passenger: + """Contains information about a passenger on the flight.""" + # The passenger's first name. + # [Optional] + first_name: str + # The passenger's last name. + # [Optional] + last_name: str + # The passenger's date of birth. + # [Optional] + # format: date (YYYY-MM-DD) + date_of_birth: str + # Contains information about the passenger's address. The spec defines exactly one + # property on this object, country. + # [Optional] + address: PassengerAddress + + +class FlightLegDetails: + """Contains information about a flight leg booked by the customer.""" + # The flight identifier. + # [Optional] + flight_number: str + # The IATA 2-letter accounting code (PAX) that identifies the carrier. Required if the + # airline data includes leg details. + # [Optional] + carrier_code: str + # A one-letter travel class identifier. The following are common: F = First class, + # J = Business class, Y = Economy class, W = Premium economy. + # [Optional] + class_of_travelling: str + # The IATA three-letter airport code of the departure airport. Required if the airline + # data includes leg details. + # [Optional] + departure_airport: str + # The date of the scheduled take off. + # [Optional] + # format: date (YYYY-MM-DD) + departure_date: str + # The time of the scheduled take off. + # [Optional] + departure_time: str + # The IATA 3-letter airport code of the destination airport. Required if the airline data + # includes leg details. + # [Optional] + arrival_airport: str + # 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] + stop_over_code: str + # The fare basis code, alphanumeric. + # [Optional] + fare_basis_code: str + + +class AirlineData: + """Contains information about the airline ticket and flights booked by the customer. + + Referenced by ProcessingSettings.airline_data and by the GET /payments/{id} response. The + class did not exist before: the only AirlineData in the SDK was the legacy ABC one in + payments_previous.py, whose shape differs, so the type comment on + ProcessingSettings.airline_data pointed at nothing in this module. + """ + # Contains information about the airline ticket. + # [Optional] + ticket: Ticket + # Contains information about the passenger(s) on the flight. + # [Optional] + # + # Assign a single Passenger, not a one-element list. Verified against the sandbox on + # 2026-09-25 with a complete 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; a list only on POST /payments. The + # spec declares the opposite, and ProcessingSettings is shared by POST /payments, hosted + # payments and payment links, so a list is not a safe default. An empty list and a null are + # both rejected, so leave the attribute unset when there are no passengers: the serializer + # only emits attributes that were assigned. Several passengers can only be expressed as a + # list, which only POST /payments accepts. Recorded in the plan under P1. + passenger: Passenger + # Contains information about the flight leg(s) booked by the customer. + # [Optional] + flight_leg_details: list # FlightLegDetails + + +class AccommodationPhone: + """Phone contact information for an accommodation property.""" + # The phone country code. + # [Optional] + country_code: str + # The phone number. + # [Optional] + number: str + + class AccommodationAddress: + """The address details of the accommodation.""" + # The first line of the address. + # [Optional] address_line1: str + # The postal code for the address. + # [Optional] zip: str class AccommodationGuest: + """Contains information about a guest staying at the accommodation.""" + # The first name of the guest. + # [Optional] first_name: str + # The last name of the guest. + # [Optional] last_name: str + # The date of birth of the guest. + # [Optional] + # format: date (YYYY-MM-DD) date_of_birth: str class AccommodationRoom: + """Contains information about a room booked by the customer.""" + # For lodging, the nightly rate for one room. For cruise, the total cost of the cruise. + # Declared as a string in the spec, not a number. + # [Optional] rate: str + # For lodging, the number of nights charged at the rate provided in the rate field. For + # cruise, the length of the cruise in days. Declared as a string in the spec. + # [Optional] number_of_nights_at_room_rate: str class AccommodationData: + """Contains information about the accommodation booked by the customer.""" + # For lodging, the lodging name that appears on the storefront/customer receipts. For + # cruise, the ship name booked for the cruise. + # [Optional] name: str + # A unique identifier for the booking. + # [Optional] booking_reference: str + # For lodging, the actual or scheduled date the guest checked-in. For cruise, the cruise + # departure date, also known as the sail date. + # [Optional] + # format: date (YYYY-MM-DD) check_in_date: str + # For lodging, the actual or scheduled date the guest checked-out. For cruise, the cruise + # return date, also known as the sail end date. + # [Optional] + # format: date (YYYY-MM-DD) check_out_date: str + # The address details of the accommodation. The spec defines only address_line1 and zip + # on this object. + # [Optional] address: AccommodationAddress + # The state or province of the address country (ISO 3166-2 code of up to two alphanumeric + # characters). A free-form string, not a country code: the spec's example is "FL". + # [Optional] state: str + # The ISO country code of the address. A free-form string rather than an alpha-2 enum: the + # spec's example is the three-letter code "USA". + # [Optional] country: str + # The address city. + # [Optional] city: str + # The total number of rooms booked for the accommodation. + # [Optional] number_of_rooms: int + # Contains information about the guests staying at the accommodation. + # [Optional] guests: list # AccommodationGuest + # Contains information about the rooms booked by the customer. + # [Optional] room: list # AccommodationRoom + # The property's phone information. + # [Optional] + property_phone: list # AccommodationPhone + # The customer service phone information. + # [Optional] + customer_service_phone: list # AccommodationPhone class Aggregator: diff --git a/tests/payments/airline_data_serialization_test.py b/tests/payments/airline_data_serialization_test.py new file mode 100644 index 00000000..90772fa0 --- /dev/null +++ b/tests/payments/airline_data_serialization_test.py @@ -0,0 +1,209 @@ +import json + +from checkout_sdk.json_serializer import JsonSerializer +from checkout_sdk.payments.payments import ( + AccommodationAddress, AccommodationData, AccommodationGuest, AccommodationPhone, + AccommodationRoom, AirlineData, FlightLegDetails, Passenger, PassengerAddress, + ProcessingSettings, Ticket, +) + + +def _serialize(obj): + return json.loads(json.dumps(obj, cls=JsonSerializer)) + + +class TestAirlineDataSerialization: + """Serialization tests for the processing.airline_data and accommodation_data sub-tree. + + The Python SDK returns responses as a ResponseWrapper over a parsed dict, so it cannot hit + the typed deserialization failure a merchant reported against another SDK. What it can get + wrong is the request: the serializer reflects assigned attributes, so an unset attribute is + absent and the shape of a value is whatever the caller assigned. + + Before this row there were no airline classes in checkout_sdk.payments.payments at all. + ProcessingSettings.airline_data carried the comment `# AirlineData`, naming a class that + existed only in payments_previous.py with the legacy ABC shape. + """ + + def test_unset_attributes_are_absent(self): + # The serializer only emits attributes that were assigned, which is what makes the + # "leave passenger unset when there are none" guidance work: an empty list and a null + # are both rejected with processing_airline_data_0_passenger_invalid. + assert _serialize(AirlineData()) == {} + assert _serialize(Ticket()) == {} + assert _serialize(Passenger()) == {} + + def test_single_passenger_serializes_as_an_object(self): + # Verified against the sandbox on 2026-09-25: a single object is accepted on every + # request surface, a list only on POST /payments. hosted payments, payment links and + # payment contexts all reject the list form. See AirlineData.passenger. + airline = AirlineData() + airline.ticket = Ticket() + airline.ticket.number = '045-21351455613' + airline.passenger = Passenger() + airline.passenger.first_name = 'John' + airline.passenger.last_name = 'White' + + result = _serialize(airline) + + assert isinstance(result['passenger'], dict) + assert result['passenger'] == {'first_name': 'John', 'last_name': 'White'} + + def test_several_passengers_serialize_as_a_list(self): + # Several passengers can only be expressed as a list, which only POST /payments accepts. + first, second = Passenger(), Passenger() + first.first_name = 'John' + second.first_name = 'Jane' + + airline = AirlineData() + airline.passenger = [first, second] + + result = _serialize(airline) + + assert isinstance(result['passenger'], list) + assert result['passenger'] == [{'first_name': 'John'}, {'first_name': 'Jane'}] + + def test_airline_data_serializes_every_spec_key(self): + ticket = Ticket() + ticket.number = '045-21351455613' + ticket.issue_date = '2023-05-20' + ticket.issuing_carrier_code = 'AI' + ticket.travel_package_indicator = 'B' + ticket.travel_agency_name = 'World Tours' + ticket.travel_agency_code = '01' + + passenger = Passenger() + passenger.first_name = 'John' + passenger.last_name = 'White' + passenger.date_of_birth = '1990-05-26' + passenger.address = PassengerAddress() + passenger.address.country = 'US' + + leg = FlightLegDetails() + leg.flight_number = '101' + leg.carrier_code = 'BA' + leg.class_of_travelling = 'J' + leg.departure_airport = 'LHR' + leg.departure_date = '2023-06-19' + leg.departure_time = '15:30' + leg.arrival_airport = 'LAX' + leg.stop_over_code = 'X' + leg.fare_basis_code = 'SPRSVR' + + airline = AirlineData() + airline.ticket = ticket + airline.passenger = passenger + airline.flight_leg_details = [leg] + + # Asserted as a whole dict, so a wrong or extra key fails here rather than passing + # because the assertion happened not to look at it. + assert _serialize(airline) == { + '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', + }], + } + + def test_passenger_address_carries_only_country(self): + # The spec defines exactly one property on passenger.address. + passenger = Passenger() + passenger.address = PassengerAddress() + passenger.address.country = 'US' + + assert _serialize(passenger) == {'address': {'country': 'US'}} + + def test_accommodation_data_serializes_every_spec_key(self): + guest = AccommodationGuest() + guest.first_name = 'Jane' + guest.last_name = 'Doe' + guest.date_of_birth = '1985-07-14' + + room = AccommodationRoom() + room.rate = '70' + room.number_of_nights_at_room_rate = '3' + + property_phone = AccommodationPhone() + property_phone.country_code = '44' + property_phone.number = '7123456789' + + service_phone = AccommodationPhone() + service_phone.country_code = '44' + service_phone.number = '7987654321' + + accommodation = AccommodationData() + accommodation.name = 'The Sea View Hotel' + accommodation.booking_reference = 'HOTEL123' + accommodation.check_in_date = '2023-06-20' + accommodation.check_out_date = '2023-06-23' + accommodation.address = AccommodationAddress() + accommodation.address.address_line1 = '123 Beach Road' + accommodation.address.zip = '10001' + accommodation.state = 'FL' + accommodation.country = 'USA' + accommodation.city = 'Los Angeles' + accommodation.number_of_rooms = 2 + accommodation.guests = [guest] + accommodation.room = [room] + accommodation.property_phone = [property_phone] + accommodation.customer_service_phone = [service_phone] + + assert _serialize(accommodation) == { + '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 and country are free-form strings: "FL" is a US state and "USA" is three + # letters, so neither fits an ISO 3166-1 alpha-2 enum. + '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 and customer_service_phone were missing from the class entirely. + 'property_phone': [{'country_code': '44', 'number': '7123456789'}], + 'customer_service_phone': [{'country_code': '44', 'number': '7987654321'}], + } + + def test_processing_settings_carries_the_airline_sub_tree(self): + # ProcessingSettings is the object POST /payments, hosted payments and payment links all + # embed as "processing", so this covers every request surface at once. + passenger = Passenger() + passenger.first_name = 'John' + + airline = AirlineData() + airline.ticket = Ticket() + airline.ticket.number = '045' + airline.passenger = passenger + + processing = ProcessingSettings() + processing.airline_data = [airline] + + result = _serialize(processing) + + assert result['airline_data'] == [ + {'ticket': {'number': '045'}, 'passenger': {'first_name': 'John'}} + ] + assert isinstance(result['airline_data'][0]['passenger'], dict) diff --git a/tests/payments/contexts/payment_contexts_airline_serialization_test.py b/tests/payments/contexts/payment_contexts_airline_serialization_test.py new file mode 100644 index 00000000..dd3122df --- /dev/null +++ b/tests/payments/contexts/payment_contexts_airline_serialization_test.py @@ -0,0 +1,175 @@ +import json + +from checkout_sdk.json_serializer import JsonSerializer +from checkout_sdk.payments.payments import ( + AccommodationData, AccommodationRoom, BillingPlan, PassengerAddress, +) +from checkout_sdk.payments.contexts.contexts import ( + PaymentContextsAirlineData, PaymentContextsFlightLegDetails, + PaymentContextsPartnerCustomerRiskData, PaymentContextsPassenger, + PaymentContextsProcessing, PaymentContextsTicket, +) + + +def _serialize(obj): + return json.loads(json.dumps(obj, cls=JsonSerializer)) + + +class TestPaymentContextsAirlineSerialization: + """Serialization tests for the payment contexts airline and accommodation sub-tree.""" + + def test_ticket_serializes_as_an_object_not_a_list(self): + # The spec declares airline_data[].ticket as a single object. It was annotated as a + # list, so the SDK sent an array, a shape the API does not accept. + airline = PaymentContextsAirlineData() + airline.ticket = PaymentContextsTicket() + airline.ticket.number = '045-21351455613' + airline.ticket.travel_package_indicator = 'B' + + result = _serialize(airline) + + assert isinstance(result['ticket'], dict) + assert result['ticket'] == { + 'number': '045-21351455613', + 'travel_package_indicator': 'B', + } + + def test_single_passenger_serializes_as_an_object(self): + # POST /payment-contexts rejects the list form with 422 passenger_required and accepts a + # single object, verified against the sandbox on 2026-09-25. + passenger = PaymentContextsPassenger() + passenger.first_name = 'John' + passenger.last_name = 'White' + passenger.address = PassengerAddress() + passenger.address.country = 'GB' + + airline = PaymentContextsAirlineData() + airline.passenger = passenger + + result = _serialize(airline) + + assert isinstance(result['passenger'], dict) + assert result['passenger'] == { + 'first_name': 'John', + 'last_name': 'White', + # The spec defines exactly one property on passenger.address. This shares + # payments.PassengerAddress rather than the wider common Address. + 'address': {'country': 'GB'}, + } + + def test_unset_passenger_is_absent(self): + # An empty list and a null are both rejected, so an unset passenger must be absent. + airline = PaymentContextsAirlineData() + airline.ticket = PaymentContextsTicket() + airline.ticket.number = '045' + + assert 'passenger' not in _serialize(airline) + + def test_airline_data_serializes_every_spec_key(self): + ticket = PaymentContextsTicket() + ticket.number = '045-21351455613' + ticket.issue_date = '2023-05-20' + ticket.issuing_carrier_code = 'AI' + ticket.travel_package_indicator = 'B' + ticket.travel_agency_name = 'World Tours' + ticket.travel_agency_code = '01' + + passenger = PaymentContextsPassenger() + passenger.first_name = 'John' + passenger.date_of_birth = '1990-05-26' + + leg = PaymentContextsFlightLegDetails() + leg.flight_number = '101' + leg.carrier_code = 'BA' + leg.class_of_travelling = 'J' + leg.departure_airport = 'LHR' + leg.departure_date = '2023-06-19' + leg.departure_time = '15:30' + leg.arrival_airport = 'LAX' + leg.stop_over_code = 'X' + leg.fare_basis_code = 'SPRSVR' + + airline = PaymentContextsAirlineData() + airline.ticket = ticket + airline.passenger = passenger + airline.flight_leg_details = [leg] + + assert _serialize(airline) == { + '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', 'date_of_birth': '1990-05-26'}, + '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', + }], + } + + def test_processing_carries_every_spec_field(self): + plan = BillingPlan() + plan.skip_shipping_address = True + + risk = PaymentContextsPartnerCustomerRiskData() + risk.key = 'risk_score' + risk.value = '42' + + room = AccommodationRoom() + room.rate = '70' + room.number_of_nights_at_room_rate = '3' + + accommodation = AccommodationData() + accommodation.name = 'The Sea View Hotel' + accommodation.state = 'FL' + accommodation.country = 'USA' + accommodation.room = [room] + + airline = PaymentContextsAirlineData() + airline.ticket = PaymentContextsTicket() + airline.ticket.number = '045' + + processing = PaymentContextsProcessing() + processing.plan = plan + processing.discount_amount = 5 + processing.shipping_amount = 300 + processing.tax_amount = 3000 + processing.invoice_id = 'INV-1' + processing.brand_name = 'Acme Corporation' + processing.locale = 'en-US' + processing.partner_customer_risk_data = [risk] + processing.custom_payment_method_ids = ['cpm_001', 'cpm_002'] + processing.airline_data = [airline] + # accommodation_data uses the shared payments.AccommodationData, because payment + # contexts, POST /payments and the GET /payments/{id} response all resolve it to the + # same specification schema. + processing.accommodation_data = [accommodation] + + assert _serialize(processing) == { + 'plan': {'skip_shipping_address': True}, + 'discount_amount': 5, + 'shipping_amount': 300, + 'tax_amount': 3000, + 'invoice_id': 'INV-1', + 'brand_name': 'Acme Corporation', + 'locale': 'en-US', + 'partner_customer_risk_data': [{'key': 'risk_score', 'value': '42'}], + 'custom_payment_method_ids': ['cpm_001', 'cpm_002'], + 'airline_data': [{'ticket': {'number': '045'}}], + 'accommodation_data': [{ + 'name': 'The Sea View Hotel', + 'state': 'FL', + 'country': 'USA', + 'room': [{'rate': '70', 'number_of_nights_at_room_rate': '3'}], + }], + } From 0e039905c287048b08ee11353fc619a850fab8f4 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Mon, 28 Sep 2026 15:19:10 +0200 Subject: [PATCH 2/4] Comments and small fixes --- checkout_sdk/payments/payments.py | 189 +++++++++++++++++- checkout_sdk/payments/payments_previous.py | 8 + .../airline_data_serialization_test.py | 36 +++- ...ent_contexts_airline_serialization_test.py | 19 +- .../payment_contexts_integration_test.py | 54 ++++- 5 files changed, 292 insertions(+), 14 deletions(-) diff --git a/checkout_sdk/payments/payments.py b/checkout_sdk/payments/payments.py index 97865969..17f0249e 100644 --- a/checkout_sdk/payments/payments.py +++ b/checkout_sdk/payments/payments.py @@ -512,11 +512,12 @@ class DLocalProcessingSettings: installments: Installments -# Deprecated: SenderInformation is not defined in the current Checkout.com API -# (NAS) swagger and no documented endpoint accepts a `sender_information` field -# on ProcessingSettings. Retained for backward compatibility with previous-API -# (ABC) callers; new code should not set this. Will be removed in a future -# major version. +# Deprecated: SenderInformation is not defined in the current Checkout.com API swagger. The +# property appears under neither `senderInformation` nor `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. The current API carries sender details in the top level `sender` +# object on the payment request instead. Retained for backward compatibility with previous-API +# (ABC) callers; new code should not set this. Will be removed in a future major version. class SenderInformation: reference: str first_name: str @@ -531,7 +532,12 @@ class SenderInformation: class PartnerCustomerRiskData: + """A key-and-value pair with merchant-specific data for the transaction.""" + # The key for the pair. + # [Optional] key: str + # The value for the pair. + # [Optional] value: str @@ -629,6 +635,10 @@ class AirlineData: class did not exist before: the only AirlineData in the SDK was the legacy ABC one in payments_previous.py, whose shape differs, so the type comment on ProcessingSettings.airline_data pointed at nothing in this module. + + Import this one for the current (NAS) API. checkout_sdk.payments.payments_previous also + defines a class called AirlineData, for the Previous (ABC) API only; its shape is different + and the current gateway discards it. """ # Contains information about the airline ticket. # [Optional] @@ -755,60 +765,223 @@ class AccommodationData: class Aggregator: + """Information about the payment aggregator.""" + # The sub-merchant ID. + # [Optional] sub_merchant_id: str + # The Visa identifier for the payment aggregator. + # [Optional] aggregator_id_visa: str + # The Mastercard identifier for the payment aggregator. + # [Optional] aggregator_id_mc: str class ProcessingSettings: + """Settings that control how the payment is processed. + + Shared across several request shapes. POST /payments resolves to PaymentRequestProcessing, + while hosted payments, payment links and payment sessions resolve to the wider + PaymentInterfacesProcessing. An attribute is therefore not necessarily read by every endpoint + that accepts this object; the attributes below name the exceptions. + """ + # The number provided by the cardholder. A purchase order or invoice number may be used. + # [Optional] + # max 15 characters order_id: str + # The total amount of sales tax on the total purchase amount. + # [Optional] + # minimum 0 tax_amount: int + # The discount amount applied to the transaction by the merchant. + # [Optional] + # minimum 0 discount_amount: int + # The total charges for any import or export duty included in the transaction. + # [Optional] + # minimum 0 duty_amount: int + # The total freight or shipping and handling charges for the transaction. + # [Optional] + # minimum 0 shipping_amount: int + # The tax amount of the freight or shipping and handling charges for the transaction. + # [Optional] + # minimum 0 shipping_tax_amount: int + # Indicates if the payment is an Account Funding Transaction. + # [Optional] aft: bool + # The preferred scheme for co-badged card payment processing. If performing 3DS through a + # third party, set this to the scheme that processed 3DS. + # [Optional] + # One of: mastercard, visa, cartes_bancaires preferred_scheme: PreferredSchema + # Indicates the reason for a merchant-initiated payment request. + # [Optional] + # One of: Delayed_charge, Resubmission, No_show, Reauthorization merchant_initiated_reason: MerchantInitiatedReason + # Unique number of the campaign this payment runs in. Only required for Afterpay campaign + # invoices. + # [Optional] campaign_id: int + # Product type of the payment. Required when source.type is wechatpay. + # [Optional] product_type: ProductType + # Value obtained from the WeChat Web Authorization API before initiating Official Account or + # Mini Program payments. Required if source.type is wechatpay. + # [Optional] open_id: str + # The payment for a merchant's order may be split; the original order price indicates the + # transaction amount of the entire order. + # [Optional] + # minimum 0 original_order_amount: int + # Merchant receipt ID. + # [Optional] + # max 32 characters receipt_id: str + # The client-side terminal type: a website opened in a desktop browser, a mobile browser, or + # a mobile application. + # [Optional] + # One of: APP, WAP, WEB terminal_type: TerminalType + # The operating system type. Required when terminal_type is not WEB. + # [Optional] + # One of: ANDROID, IOS os_type: OsType + # Invoice ID number. + # [Optional] + # max 127 characters invoice_id: str + # The label that overrides the business name in the PayPal account on the PayPal pages. + # [Optional] + # max 127 characters brand_name: str + # The language and region of the customer in ISO 639-2 language code; the value consists of + # language-country. + # [Optional] + # pattern ^[a-z]{2}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}))?$ + # 2 to 10 characters locale: str + # Shipping preference. Declared on PaymentContextProcessing only, so it is read by + # POST /payment-contexts and not by POST /payments, hosted payments or payment links. + # [Optional] + # One of: no_shipping, set_provided_address, get_from_file shipping_preference: ShippingPreference + # Property required by PayPal to have an appropriate payment flow. Declared on + # PaymentContextProcessing only. + # [Optional] + # One of: pay_now, continue user_action: UserAction + # Not in the current specification, neither NAS nor Previous (ABC). The gateway discards it. + # Retained for backwards compatibility. + # [Optional] set_transaction_context: list # dict + # Contains information about the airline ticket and flights booked by the customer. + # [Optional] airline_data: list # AirlineData + # One time password sent to the customer by SMS. Declared on the payment contexts payment + # request and on the capture request, not on PaymentRequestProcessing. + # [Optional] + # max 50 characters otp_value: str + # The two-letter ISO country code of the purchase country. + # [Optional] + # max 2 characters purchase_country: Country - custom_payment_method_ids: list # string + # Promo codes. They define which of the configured payment options within a payment category + # (pay_later, pay_over_time, and so on) are shown for this purchase. + # [Optional] + custom_payment_method_ids: list # str + # A URL you can use to notify the customer that the order has been created. + # [Optional] merchant_callback_url: str + # The line of business for the payment. Beta. + # [Optional] line_of_business: str + # Not in the current specification, neither NAS nor Previous (ABC). The gateway discards it. + # Retained for backwards compatibility. + # [Optional] shipping_delay: int + # Not in the current specification, neither NAS nor Previous (ABC). The gateway discards it. + # Retained for backwards compatibility. + # [Optional] shipping_info: list # ShippingInfo + # Previous API (ABC) only; absent from the NAS processing schemas. + # [Optional] dlocal: DLocalProcessingSettings - # Deprecated: see SenderInformation class — no current API endpoint reads this. + # Previous API (ABC) only, and not in any available specification. See the SenderInformation + # class. Left exactly as it was on purpose: the serializer sends this as sender_information, + # and there is no evidence establishing which key, if either, the gateway reads, so no + # _KEYS_TRANSFORMATIONS entry overrides it. + # [Optional] sender_information: SenderInformation + # Not declared on any processing schema in either specification. The name appears elsewhere + # in the spec on unrelated objects. The gateway discards it here. + # [Optional] purpose: str + # Key-and-value pairs with merchant-specific data for the transaction. + # [Optional] partner_customer_risk_data: list # PartnerCustomerRiskData + # Contains information about the accommodation booked by the customer. + # [Optional] accommodation_data: list # AccommodationData + # Surcharge amount applied to the transaction by the merchant, in the minor currency unit. + # [Optional] + # minimum 0 surcharge_amount: int + # Specifies the preferred type of Primary Account Number (PAN) for the payment. Only applies + # when source.type is a card, instrument or token. + # [Optional] + # One of: fpan, dpan pan_preference: PanPreference + # Indicates whether to provision a network token for the payment. + # [Optional] provision_network_token: bool + # The unique identifier for Visa-registered ramp providers. Required if you are a + # Visa-registered ramp provider operating with affiliates. + # [Optional] + # pattern ^[a-zA-Z0-9]{1,15}$ + # max 15 characters affiliate_id: str + # The affiliate URL. Required if you are a Visa-registered ramp provider operating with + # affiliates. + # [Optional] affiliate_url: str + # Information about the payment aggregator. + # [Optional] aggregator: Aggregator + # Specifies whether to process the payment as a credit or debit transaction, if a combo card + # is used. Required for domestic payments in Brazil. + # [Optional] + # One of: credit, debit card_type: CardFundingType + # The foreign retailer amount the merchant applied to the transaction, in the minor currency + # unit. + # [Optional] + # minimum 0 foreign_retailer_amount: int + # The transaction identifier used to track a payment request. + # [Optional] reconciliation_id: str + # Specifies which ACH service to use for the payment, if you set source.type to ach. + # [Optional] + # One of: same_day, standard service_type: ServiceType + # The customer's 6-digit Blik code. Required when source.type is blik and merchant_initiated + # is false (for example, for Regular payments and the initial payment of a Recurring + # agreement). + # [Optional] + # pattern ^\d{6}$ + # 6 characters partner_code: str - processing_speed: str # 'fast' (only for unreferenced refunds / card payouts) + # Not declared on any processing component schema; it appears only in inline schemas. + # 'fast' (only for unreferenced refunds / card payouts) + # [Optional] + processing_speed: str + # The scheme transaction link identifier. + # [Optional] scheme_transaction_link_id: str diff --git a/checkout_sdk/payments/payments_previous.py b/checkout_sdk/payments/payments_previous.py index ef5a1908..d43bed1d 100644 --- a/checkout_sdk/payments/payments_previous.py +++ b/checkout_sdk/payments/payments_previous.py @@ -76,6 +76,14 @@ class AirlineFlightLegDetails: class AirlineData: + """Previous API (ABC) airline data. NOT the current shape. + + Distinct from checkout_sdk.payments.payments.AirlineData, which maps the current (NAS) + schema and is what ProcessingSettings.airline_data expects. The two share a class name and + differ in shape: this one nests the passenger name under AirlinePassengerName.full_name and + spells the flight-leg fields service_class and stopover_code, none of which the current API + defines. Importing this one by mistake builds a payload the gateway discards. + """ ticket: AirlineTicket passenger: AirlinePassenger flight_leg_details: list # AirlineFlightLegDetails diff --git a/tests/payments/airline_data_serialization_test.py b/tests/payments/airline_data_serialization_test.py index 90772fa0..31b87cb2 100644 --- a/tests/payments/airline_data_serialization_test.py +++ b/tests/payments/airline_data_serialization_test.py @@ -95,9 +95,32 @@ def test_airline_data_serializes_every_spec_key(self): airline.passenger = passenger airline.flight_leg_details = [leg] + result = _serialize(airline) + + # A whole-dict == ignores key order, so order is pinned separately. Note this SDK emits + # keys ALPHABETICALLY, not in declaration or specification order, because JsonSerializer + # reflects with inspect.getmembers() which sorts by name. That is harmless (JSON object + # order is not semantic and the API accepts it) but it is a real difference from the + # other SDKs, whose serializers preserve declaration order. Pinned here so a change to + # the encoder -- for example reflecting __dict__, which preserves insertion order -- + # shows up as a test failure rather than a silent change in every payload. + assert list(result.keys()) == ['flight_leg_details', 'passenger', 'ticket'] + assert list(result['ticket'].keys()) == [ + 'issue_date', 'issuing_carrier_code', 'number', 'travel_agency_code', + 'travel_agency_name', 'travel_package_indicator', + ] + assert list(result['passenger'].keys()) == [ + 'address', 'date_of_birth', 'first_name', 'last_name', + ] + assert list(result['flight_leg_details'][0].keys()) == [ + 'arrival_airport', 'carrier_code', 'class_of_travelling', 'departure_airport', + 'departure_date', 'departure_time', 'fare_basis_code', 'flight_number', + 'stop_over_code', + ] + # Asserted as a whole dict, so a wrong or extra key fails here rather than passing # because the assertion happened not to look at it. - assert _serialize(airline) == { + assert result == { 'ticket': { 'number': '045-21351455613', 'issue_date': '2023-05-20', @@ -168,7 +191,16 @@ def test_accommodation_data_serializes_every_spec_key(self): accommodation.property_phone = [property_phone] accommodation.customer_service_phone = [service_phone] - assert _serialize(accommodation) == { + result = _serialize(accommodation) + + # Alphabetical, per the note in test_airline_data_serializes_every_spec_key. + assert list(result.keys()) == [ + 'address', 'booking_reference', 'check_in_date', 'check_out_date', 'city', 'country', + 'customer_service_phone', 'guests', 'name', 'number_of_rooms', 'property_phone', + 'room', 'state', + ] + + assert result == { 'name': 'The Sea View Hotel', 'booking_reference': 'HOTEL123', 'check_in_date': '2023-06-20', diff --git a/tests/payments/contexts/payment_contexts_airline_serialization_test.py b/tests/payments/contexts/payment_contexts_airline_serialization_test.py index dd3122df..fac832be 100644 --- a/tests/payments/contexts/payment_contexts_airline_serialization_test.py +++ b/tests/payments/contexts/payment_contexts_airline_serialization_test.py @@ -94,7 +94,13 @@ def test_airline_data_serializes_every_spec_key(self): airline.passenger = passenger airline.flight_leg_details = [leg] - assert _serialize(airline) == { + result = _serialize(airline) + + # Alphabetical: JsonSerializer reflects with inspect.getmembers(), which sorts by + # name. Harmless on the wire, but pinned so an encoder change is visible. + assert list(result.keys()) == ['flight_leg_details', 'passenger', 'ticket'] + + assert result == { 'ticket': { 'number': '045-21351455613', 'issue_date': '2023-05-20', @@ -155,7 +161,16 @@ def test_processing_carries_every_spec_field(self): # same specification schema. processing.accommodation_data = [accommodation] - assert _serialize(processing) == { + result = _serialize(processing) + + # Alphabetical, as above. + assert list(result.keys()) == [ + 'accommodation_data', 'airline_data', 'brand_name', 'custom_payment_method_ids', + 'discount_amount', 'invoice_id', 'locale', 'partner_customer_risk_data', 'plan', + 'shipping_amount', 'tax_amount', + ] + + assert result == { 'plan': {'skip_shipping_address': True}, 'discount_amount': 5, 'shipping_amount': 300, diff --git a/tests/payments/contexts/payment_contexts_integration_test.py b/tests/payments/contexts/payment_contexts_integration_test.py index e8fefd7c..a9102db6 100644 --- a/tests/payments/contexts/payment_contexts_integration_test.py +++ b/tests/payments/contexts/payment_contexts_integration_test.py @@ -6,8 +6,10 @@ from checkout_sdk.common.common import AccountHolder, Address from checkout_sdk.common.enums import Currency, Country from checkout_sdk.payments.contexts.contexts import PaymentContextsRequest, PaymentContextsItems, \ - PaymentContextPaypalSource, PaymentContextKlarnaSource, PaymentContextsProcessing -from checkout_sdk.payments.payments import PaymentType + PaymentContextPaypalSource, PaymentContextKlarnaSource, PaymentContextsProcessing, \ + PaymentContextsAirlineData, PaymentContextsFlightLegDetails, PaymentContextsPassenger, \ + PaymentContextsTicket +from checkout_sdk.payments.payments import PaymentType, PassengerAddress from tests.checkout_test_utils import assert_response, APM_SERVICE_UNAVAILABLE, check_error_item @@ -72,6 +74,54 @@ def test_create_payment_contexts_klarna_request(default_api): payment_contexts_request=request) +def test_create_payment_contexts_with_airline_data(default_api): + """Sends processing.airline_data to a live endpoint. + + POST /payment-contexts rejects the array form of passenger with 422 passenger_required and + accepts a single object, which is the opposite of what the specification declares. Nothing + else in this suite sends airline data anywhere, and the equivalent test in the Go SDK is what + caught that mistake before it reached a merchant. A 422 here means the guidance documented on + AirlineData.passenger no longer matches what the endpoint accepts. + + stop_over_code is deliberately omitted: this endpoint rejects it with + flight_leg_detail_stop_over_code_invalid for every value tried, including the three its own + description names and the one in the swagger example. Raised as a spec/API defect. + """ + ticket = PaymentContextsTicket() + ticket.number = '045-21351455613' + ticket.issuing_carrier_code = 'AI' + ticket.travel_package_indicator = 'B' + + # A single object, not a one-element list. See payments.AirlineData.passenger. + passenger = PaymentContextsPassenger() + passenger.first_name = 'John' + passenger.last_name = 'White' + passenger.address = PassengerAddress() + passenger.address.country = Country.GB + + leg = PaymentContextsFlightLegDetails() + leg.flight_number = '101' + leg.carrier_code = 'BA' + leg.class_of_travelling = 'J' + leg.departure_airport = 'LHR' + leg.arrival_airport = 'LAX' + + airline = PaymentContextsAirlineData() + airline.ticket = ticket + airline.passenger = passenger + airline.flight_leg_details = [leg] + + processing = PaymentContextsProcessing() + processing.airline_data = [airline] + + request = create_payment_contexts_request() + request.processing = processing + + response = default_api.contexts.create_payment_contexts(request) + + assert_response(response, 'http_metadata', 'id') + + def create_payment_contexts_request(): source = PaymentContextPaypalSource() From 4bcdc6cd465d44d308015de72d7f292578f7b043 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Tue, 29 Sep 2026 11:20:00 +0200 Subject: [PATCH 3/4] Type fix --- checkout_sdk/payments/contexts/contexts.py | 6 +-- checkout_sdk/payments/payments.py | 18 ++++--- .../processing_fractional_amount_test.py | 48 +++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 tests/payments/processing_fractional_amount_test.py diff --git a/checkout_sdk/payments/contexts/contexts.py b/checkout_sdk/payments/contexts/contexts.py index 2bd3250a..87edc502 100644 --- a/checkout_sdk/payments/contexts/contexts.py +++ b/checkout_sdk/payments/contexts/contexts.py @@ -124,7 +124,7 @@ class PaymentContextsProcessing: plan: BillingPlan # The total freight or shipping and handling charges for the transaction. # [Optional] - shipping_amount: int + shipping_amount: float # Invoice ID number. # [Optional] invoice_id: str @@ -160,10 +160,10 @@ class PaymentContextsProcessing: custom_payment_method_ids: list # str # The discount amount the merchant applied to the transaction. # [Optional] - discount_amount: int + discount_amount: float # The total tax amount for the transaction, in the minor currency unit. # [Optional] - tax_amount: int + tax_amount: float class PaymentContextsItems: diff --git a/checkout_sdk/payments/payments.py b/checkout_sdk/payments/payments.py index 17f0249e..b0a96f5b 100644 --- a/checkout_sdk/payments/payments.py +++ b/checkout_sdk/payments/payments.py @@ -792,23 +792,23 @@ class ProcessingSettings: # The total amount of sales tax on the total purchase amount. # [Optional] # minimum 0 - tax_amount: int + tax_amount: float # The discount amount applied to the transaction by the merchant. # [Optional] # minimum 0 - discount_amount: int + discount_amount: float # The total charges for any import or export duty included in the transaction. # [Optional] # minimum 0 - duty_amount: int + duty_amount: float # The total freight or shipping and handling charges for the transaction. # [Optional] # minimum 0 - shipping_amount: int + shipping_amount: float # The tax amount of the freight or shipping and handling charges for the transaction. # [Optional] # minimum 0 - shipping_tax_amount: int + shipping_tax_amount: float # Indicates if the payment is an Account Funding Transaction. # [Optional] aft: bool @@ -836,7 +836,7 @@ class ProcessingSettings: # transaction amount of the entire order. # [Optional] # minimum 0 - original_order_amount: int + original_order_amount: float # Merchant receipt ID. # [Optional] # max 32 characters @@ -923,7 +923,11 @@ class ProcessingSettings: purpose: str # Key-and-value pairs with merchant-specific data for the transaction. # [Optional] - partner_customer_risk_data: list # PartnerCustomerRiskData + # The specification declares this as a single object with `key` and `value`, even though + # its description calls it "an array of key-and-value pairs". The sandbox accepts both a + # bare object and an array; Java, .NET, Go and Ruby all model the declared single object, + # so this follows them rather than keeping a third shape in the family. + partner_customer_risk_data: PartnerCustomerRiskData # Contains information about the accommodation booked by the customer. # [Optional] accommodation_data: list # AccommodationData diff --git a/tests/payments/processing_fractional_amount_test.py b/tests/payments/processing_fractional_amount_test.py new file mode 100644 index 00000000..d22ab725 --- /dev/null +++ b/tests/payments/processing_fractional_amount_test.py @@ -0,0 +1,48 @@ +import json + +from checkout_sdk.json_serializer import JsonSerializer +from checkout_sdk.payments.payments import ProcessingSettings + + +# The swagger types tax_amount, discount_amount, shipping_amount, shipping_tax_amount, +# duty_amount and original_order_amount as `number`, not `integer`, and the live API honours that: +# POST /payments with "tax_amount": 10.5 returns 201 and GET /payments/{id} echoes 10.5 back. +# Python has no runtime type enforcement, so the annotations saying `int` never broke anything, +# but they were the documented contract and told merchants a fractional amount was invalid. +# Java threw and Go failed the whole response on the same data; see those SDKs' tests. +def test_serializes_fractional_processing_amounts(): + settings = ProcessingSettings() + settings.tax_amount = 10.5 + settings.discount_amount = 0.25 + settings.shipping_amount = 3.75 + settings.shipping_tax_amount = 1.5 + settings.duty_amount = 2.05 + settings.original_order_amount = 99.99 + + body = json.loads(json.dumps(settings, cls=JsonSerializer)) + + assert body['tax_amount'] == 10.5 + assert body['discount_amount'] == 0.25 + assert body['shipping_amount'] == 3.75 + assert body['shipping_tax_amount'] == 1.5 + assert body['duty_amount'] == 2.05 + assert body['original_order_amount'] == 99.99 + + +def test_serializes_whole_processing_amounts_unchanged(): + settings = ProcessingSettings() + settings.tax_amount = 3000 + + body = json.loads(json.dumps(settings, cls=JsonSerializer)) + + assert body['tax_amount'] == 3000 + + +def test_annotations_declare_float_not_int(): + # Pins the annotation itself, because it is the merchant-visible contract and an `int` here + # is what every other SDK in the family encoded as a hard type before this fix. + import typing + hints = typing.get_type_hints(ProcessingSettings) + for field in ('tax_amount', 'discount_amount', 'shipping_amount', + 'shipping_tax_amount', 'duty_amount', 'original_order_amount'): + assert hints[field] is float, f'{field} should be annotated float, got {hints[field]}' From 3207557551aa2ec7ae7f68c243f257a60718468b Mon Sep 17 00:00:00 2001 From: david ruiz Date: Tue, 29 Sep 2026 16:00:57 +0200 Subject: [PATCH 4/4] AirlineData passenger type union --- checkout_sdk/payments/payments.py | 32 ++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/checkout_sdk/payments/payments.py b/checkout_sdk/payments/payments.py index b0a96f5b..d4d0a400 100644 --- a/checkout_sdk/payments/payments.py +++ b/checkout_sdk/payments/payments.py @@ -2,6 +2,7 @@ from datetime import datetime from enum import Enum +from typing import List, Union from checkout_sdk.common.common import AccountHolder, BankDetails, MarketplaceData, Address, Phone, CustomerRequest, \ AccountHolderIdentification, QueryFilterDateRange @@ -646,22 +647,31 @@ class did not exist before: the only AirlineData in the SDK was the legacy ABC o # Contains information about the passenger(s) on the flight. # [Optional] # - # Assign a single Passenger, not a one-element list. Verified against the sandbox on - # 2026-09-25 with a complete airline_data block: + # Accepts a single Passenger or a list of them, and the choice is not cosmetic. Every row + # below was sent to the sandbox, the first four on 2026-09-25 and re-verified with the fifth + # on 2026-09-28: # # 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-sessions 201 201 + # POST /hosted-payments 201 422 processing_airline_data_0_passenger_invalid + # POST /payment-links 201 422 processing_airline_data_0_passenger_invalid # POST /payment-contexts 201 422 passenger_required # - # A single object is accepted on every request surface; a list only on POST /payments. The - # spec declares the opposite, and ProcessingSettings is shared by POST /payments, hosted - # payments and payment links, so a list is not a safe default. An empty list and a null are - # both rejected, so leave the attribute unset when there are no passengers: the serializer - # only emits attributes that were assigned. Several passengers can only be expressed as a - # list, which only POST /payments accepts. Recorded in the plan under P1. - passenger: Passenger + # So prefer a single Passenger: that is accepted on every request surface. Use a list only + # for two or more passengers, and only against POST /payments or POST /payment-sessions, + # which are the only surfaces that take it. ProcessingSettings is shared by POST /payments, + # hosted payments and payment links, so a list is not a safe default even though the + # specification declares the property array-only. + # + # Note that hosted payments, payment links and payment sessions all resolve to the same + # PaymentInterfacesProcessing schema, yet the first two reject the array and the third + # accepts it: validation is per endpoint, not per schema. + # + # An empty list and an explicit null are both rejected, so leave the attribute unset when + # there are no passengers; the serializer only emits attributes that were assigned. + # Recorded in the plan under P1. + passenger: Union[Passenger, List[Passenger]] # Contains information about the flight leg(s) booked by the customer. # [Optional] flight_leg_details: list # FlightLegDetails