diff --git a/lib/active_merchant/billing/gateways/paysafe.rb b/lib/active_merchant/billing/gateways/paysafe.rb index 06c2f4316e0..cfafde2c1ab 100644 --- a/lib/active_merchant/billing/gateways/paysafe.rb +++ b/lib/active_merchant/billing/gateways/paysafe.rb @@ -20,7 +20,6 @@ def initialize(options = {}) def purchase(money, payment, options = {}) post = {} add_auth_purchase_params(post, money, payment, options) - add_airline_travel_details(post, options) add_split_pay_details(post, options) post[:settleWithAuth] = true @@ -129,6 +128,8 @@ def add_auth_purchase_params(post, money, payment, options) add_three_d_secure(post, payment, options) if options[:three_d_secure] add_stored_credential(post, options) if options[:stored_credential] add_funding_transaction(post, options) + add_airline_travel_details(post, options) + add_cruiseline_travel_details(post, options) end # Customer data can be included in transactions where the payment method is a credit card @@ -230,30 +231,112 @@ def add_three_d_secure(post, payment, options) post[:authentication][:directoryServerTransactionId] = three_d_secure[:ds_transaction_id] unless payment.is_a?(String) || !mastercard?(payment) end + def add_cruiseline_travel_details(post, options) + return unless cruiseline_data = options[:cruiseline_travel_details] + + post[:cruiselineTravelDetails] = {} + post[:cruiselineTravelDetails][:cruiseShipName] = cruiseline_data[:cruise_ship_name] if cruiseline_data[:cruise_ship_name] + post[:cruiselineTravelDetails][:passengerName] = cruiseline_data[:passenger_name] if cruiseline_data[:passenger_name] + post[:cruiselineTravelDetails][:departureDate] = cruiseline_data[:departure_date] if cruiseline_data[:departure_date] + post[:cruiselineTravelDetails][:returnDate] = cruiseline_data[:return_date] if cruiseline_data[:return_date] + post[:cruiselineTravelDetails][:country] = cruiseline_data[:country] if cruiseline_data[:country] + post[:cruiselineTravelDetails][:state] = cruiseline_data[:state] if cruiseline_data[:state] + post[:cruiselineTravelDetails][:originCity] = cruiseline_data[:origin_city] if cruiseline_data[:origin_city] + post[:cruiselineTravelDetails][:roomRate] = cruiseline_data[:room_rate] if cruiseline_data[:room_rate] + post[:cruiselineTravelDetails][:travelPackageApplication] = cruiseline_data[:travel_package_application] if cruiseline_data[:travel_package_application] + + add_cruiseline_ticket_details(post, options) + add_cruiseline_passenger_details(post, options) + add_cruisline_trip_leg_details(post, options) + end + + def add_cruiseline_ticket_details(post, options) + return unless ticket = options[:cruiseline_travel_details][:ticket] + + post[:cruiselineTravelDetails][:ticket] = {} + post[:cruiselineTravelDetails][:ticket][:ticketNumber] = ticket[:ticket_number] if ticket[:ticket_number] + post[:cruiselineTravelDetails][:ticket][:isRestrictedTicket] = ticket[:is_restricted_ticket].to_s.present? + end + + def add_cruiseline_passenger_details(post, options) + return unless passengers = options[:cruiseline_travel_details][:passengers] + + passengers_hash = {} + passengers.each.with_index(1) do |passenger, i| + my_passenger = "passenger#{i}".to_sym + details = add_passenger_details(my_passenger, passenger[1]) + + passengers_hash[my_passenger] = details + end + post[:cruiselineTravelDetails][:passengers] = passengers_hash + end + + def add_passenger_details(obj, passenger) + details = {} + + details[:ticketNumber] = passenger[:ticket_number] if passenger[:ticket_number] + details[:firstName] = passenger[:first_name] if passenger[:first_name] + details[:lastName] = passenger[:last_name] if passenger[:last_name] + details[:phoneNumber] = passenger[:phone_number] if passenger[:phone_number] + details[:passengerCode] = passenger[:passenger_code] if passenger[:passenger_code] + details[:gender] = passenger[:gender] if passenger[:gender] + + details + end + + def add_cruisline_trip_leg_details(post, options) + return unless trip_legs = options[:cruiseline_travel_details][:trip_legs] + + trip_legs_hash = {} + trip_legs.each.with_index(1) do |leg, i| + my_leg = "leg#{i}".to_sym + details = add_cruiseline_leg_details(my_leg, leg[1]) + + trip_legs_hash[my_leg] = details + end + post[:cruiselineTravelDetails][:tripLegs] = trip_legs_hash + end + + def add_cruiseline_leg_details(obj, leg) + details = {} + + details[:serviceClass] = leg[:service_class] if leg[:service_class] + details[:departureCity] = leg[:departure_city] if leg[:departure_city] + details[:destinationCity] = leg[:destination_city] if leg[:destination_city] + details[:fare] = leg[:fare] if leg[:fare] + details[:departureDate] = leg[:departure_date] if leg[:departure_date] + + details + end + def add_airline_travel_details(post, options) - return unless options[:airline_travel_details] + return unless airline_data = options[:airline_travel_details] post[:airlineTravelDetails] = {} - post[:airlineTravelDetails][:passengerName] = options[:airline_travel_details][:passenger_name] if options[:airline_travel_details][:passenger_name] - post[:airlineTravelDetails][:departureDate] = options[:airline_travel_details][:departure_date] if options[:airline_travel_details][:departure_date] - post[:airlineTravelDetails][:origin] = options[:airline_travel_details][:origin] if options[:airline_travel_details][:origin] - post[:airlineTravelDetails][:computerizedReservationSystem] = options[:airline_travel_details][:computerized_reservation_system] if options[:airline_travel_details][:computerized_reservation_system] - post[:airlineTravelDetails][:customerReferenceNumber] = options[:airline_travel_details][:customer_reference_number] if options[:airline_travel_details][:customer_reference_number] + post[:airlineTravelDetails][:passengerName] = airline_data[:passenger_name] if airline_data[:passenger_name] + post[:airlineTravelDetails][:departureDate] = airline_data[:departure_date] if airline_data[:departure_date] + post[:airlineTravelDetails][:origin] = airline_data[:origin] if airline_data[:origin] + post[:airlineTravelDetails][:computerizedReservationSystem] = airline_data[:computerized_reservation_system] if airline_data[:computerized_reservation_system] + post[:airlineTravelDetails][:customerReferenceNumber] = airline_data[:customer_reference_number] if airline_data[:customer_reference_number] - add_ticket_details(post, options) - add_travel_agency_details(post, options) - add_trip_legs(post, options) + add_airline_ticket_details(post, options) + add_airline_travel_agency_details(post, options) + add_airline_trip_legs(post, options) end - def add_ticket_details(post, options) + def add_airline_ticket_details(post, options) return unless ticket = options[:airline_travel_details][:ticket] post[:airlineTravelDetails][:ticket] = {} post[:airlineTravelDetails][:ticket][:ticketNumber] = ticket[:ticket_number] if ticket[:ticket_number] - post[:airlineTravelDetails][:ticket][:isRestrictedTicket] = ticket[:is_restricted_ticket] if ticket[:is_restricted_ticket] + post[:airlineTravelDetails][:ticket][:isRestrictedTicket] = ticket[:is_restricted_ticket].to_s.present? + post[:airlineTravelDetails][:ticket][:cityOfTicketIssuing] = ticket[:city_of_ticket_issuing] if ticket[:city_of_ticket_issuing] + post[:airlineTravelDetails][:ticket][:ticketDeliveryMethod] = ticket[:ticket_delivery_method] if ticket[:ticket_delivery_method] + post[:airlineTravelDetails][:ticket][:ticketIssueDate] = ticket[:ticket_issue_date] if ticket[:ticket_issue_date] + post[:airlineTravelDetails][:ticket][:numberOfPax] = ticket[:number_of_pax] if ticket[:number_of_pax] end - def add_travel_agency_details(post, options) + def add_airline_travel_agency_details(post, options) return unless agency = options[:airline_travel_details][:travel_agency] post[:airlineTravelDetails][:travelAgency] = {} @@ -261,24 +344,24 @@ def add_travel_agency_details(post, options) post[:airlineTravelDetails][:travelAgency][:code] = agency[:code] if agency[:code] end - def add_trip_legs(post, options) + def add_airline_trip_legs(post, options) return unless trip_legs = options[:airline_travel_details][:trip_legs] trip_legs_hash = {} trip_legs.each.with_index(1) do |leg, i| my_leg = "leg#{i}".to_sym - details = add_leg_details(my_leg, leg[1]) + details = add_airline_leg_details(my_leg, leg[1]) trip_legs_hash[my_leg] = details end post[:airlineTravelDetails][:tripLegs] = trip_legs_hash end - def add_leg_details(obj, leg) + def add_airline_leg_details(obj, leg) details = {} add_flight_details(details, obj, leg) details[:serviceClass] = leg[:service_class] if leg[:service_class] - details[:isStopOverAllowed] = leg[:is_stop_over_allowed] if leg[:is_stop_over_allowed] + details[:isStopOverAllowed] = leg[:is_stop_over_allowed].to_s.present? details[:destination] = leg[:destination] if leg[:destination] details[:fareBasis] = leg[:fare_basis] if leg[:fare_basis] details[:departureDate] = leg[:departure_date] if leg[:departure_date] diff --git a/test/remote/gateways/remote_paysafe_test.rb b/test/remote/gateways/remote_paysafe_test.rb index 3f1d9345d7b..a98004ec8f0 100644 --- a/test/remote/gateways/remote_paysafe_test.rb +++ b/test/remote/gateways/remote_paysafe_test.rb @@ -71,7 +71,11 @@ def setup computerized_reservation_system: 'DATS', ticket: { ticket_number: 9876789, - is_restricted_ticket: false + is_restricted_ticket: false, + city_of_ticket_issuing: 'Porto', + ticket_delivery_method: 'E_TICKET', + ticket_issue_date: '2026-01-26', + number_of_pax: 4 }, customer_reference_number: 107854099, travel_agency: { @@ -104,6 +108,58 @@ def setup } } } + + @cruiseline_details = { + cruiseline_travel_details: { + cruise_ship_name: 'Cruise Ship', + passenger_name: 'Joe Smith', + departure_date: '2026-11-30', + return_date: '2026-12-30', + country: 'US', + state: 'CA', + origin_city: 'SXF', + room_rate: 1200, + travel_package_application: 'CAR_RENTAL_RESERVATION', + ticket: { + ticket_number: 9876789, + is_restricted_ticket: false + }, + passengers: { + passenger1: { + ticket_number: '12J13J1', + first_name: 'Joe', + last_name: 'Smith', + phone_number: '8888994222', + passenger_code: 'INF', + gender: 'M' + }, + passenger2: { + ticket_number: '12J12J1', + first_name: 'Jane', + last_name: 'Smith', + phone_number: '8888994223', + passenger_code: 'INF', + gender: 'F' + } + }, + trip_legs: { + leg1: { + service_class: 'F', + departure_city: 'DOM', + destination_city: 'BDS', + fare: 10, + departure_date: '2026-11-30' + }, + leg2: { + service_class: 'F', + departure_city: 'DOM', + destination_city: 'BDS', + fare: 10, + departure_date: '2026-11-30' + } + } + } + } end def test_successful_purchase @@ -150,6 +206,17 @@ def test_successful_purchase_with_airline_details assert_equal 'F', response.params['airlineTravelDetails']['tripLegs']['leg2']['serviceClass'] end + def test_successful_purchase_with_cruiseline_details + response = @gateway.purchase(@amount, @credit_card, @options.merge(@cruiseline_details)) + + assert_success response + assert_equal 'COMPLETED', response.message + assert_equal 'M', response.params['cruiselineTravelDetails']['passengers']['passenger1']['gender'] + assert_equal 'INF', response.params['cruiselineTravelDetails']['passengers']['passenger2']['passengerCode'] + assert_equal 'BDS', response.params['cruiselineTravelDetails']['tripLegs']['leg1']['destinationCity'] + assert_equal 'F', response.params['cruiselineTravelDetails']['tripLegs']['leg2']['serviceClass'] + end + def test_successful_purchase_with_truncated_address options = { billing_address: { diff --git a/test/unit/gateways/paysafe_test.rb b/test/unit/gateways/paysafe_test.rb index 3e6e1ebee10..152111d50fc 100644 --- a/test/unit/gateways/paysafe_test.rb +++ b/test/unit/gateways/paysafe_test.rb @@ -97,6 +97,70 @@ def test_successful_purchase_with_airline_details assert_success response end + def test_successful_purchase_with_cruiseline_details + cruiseline_details = { + cruiseline_travel_details: { + cruise_ship_name: 'CruiseShip', + passenger_name: 'Joe Smith', + departure_date: '2026-11-30', + return_date: '2026-12-30', + country: 'US', + state: 'CA', + origin_city: 'SXF', + room_rate: 1200, + travel_package_application: 'CAR_RENTAL_RESERVATION', + ticket: { + ticket_number: 9876789, + is_restricted_ticket: false + }, + passengers: { + passenger1: { + ticket_number: '12J13J1', + first_name: 'Joe', + last_name: 'Smith', + phone_number: '8888994222', + passenger_code: 'INF', + gender: 'M' + }, + passenger2: { + ticket_number: '12J12J1', + first_name: 'Jane', + last_name: 'Smith', + phone_number: '8888994223', + passenger_code: 'INF', + gender: 'F' + } + }, + trip_legs: { + leg1: { + service_class: 'F', + departure_city: 'DOM', + destination_city: 'BDS', + fare: 10, + departure_date: '2026-11-30' + }, + leg2: { + service_class: 'F', + departure_city: 'DOM', + destination_city: 'BDS', + fare: 10, + departure_date: '2026-11-30' + } + } + } + } + response = stub_comms(@gateway, :ssl_request) do + @gateway.purchase(@amount, @credit_card, cruiseline_details) + end.check_request do |_method, _endpoint, data, _headers| + assert_match(/"cruiselineTravelDetails"/, data) + assert_match(/"cruiseShipName":"CruiseShip"/, data) + assert_match(/"tripLegs":{"leg1":{"serviceClass":"F"/, data) + assert_match(/"passengers":{"passenger1":{"ticketNumber":"12J13J1"/, data) + end.respond_with(successful_purchase_response) + + assert_success response + end + def test_successful_purchase_with_stored_credentials stored_credential_options = { stored_credential: {