From b903b74c03695b866cac170d70a75383a389b432 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 28 May 2026 18:45:36 +0300 Subject: [PATCH] feat(offer-requests): expose split-ticket itineraries Adds support for the split-ticket itineraries flow on POST /air/offer_requests: - New body field `include_split_ticket` on `CreateOfferRequest`, which opts a multi-slice offer request into the per-slice fan-out the API performs to find split-ticket candidates. - New `view` query parameter (`'offers' | 'itineraries'`) on `CreateOfferRequestQueryParameters`. When set to `itineraries`, the response is grouped per slice into itineraries and fare brands. - New response types covering the itineraries view: `OfferRequestItinerariesView`, `OfferRequestItinerariesViewSlice`, `Itinerary`, `ItineraryBrand`, `ItineraryOffer` and the `ItineraryOfferType` discriminator (`'single_ticket' | 'split_ticket'`). - `OfferRequests.create` returns a conditional type based on the `view` parameter so itineraries-view callers get the grouped shape and offers-view callers retain the existing typing (including the `return_offers: false` discriminator). See https://duffel.com/docs/guides/selling-split-ticket-itineraries. --- .../OfferRequests/OfferRequests.spec.ts | 68 ++++++++- src/booking/OfferRequests/OfferRequests.ts | 19 ++- .../OfferRequests/OfferRequestsTypes.ts | 141 +++++++++++++++++- src/booking/OfferRequests/mockOfferRequest.ts | 46 +++++- 4 files changed, 267 insertions(+), 7 deletions(-) diff --git a/src/booking/OfferRequests/OfferRequests.spec.ts b/src/booking/OfferRequests/OfferRequests.spec.ts index 194bc112..d7d6667b 100644 --- a/src/booking/OfferRequests/OfferRequests.spec.ts +++ b/src/booking/OfferRequests/OfferRequests.spec.ts @@ -1,7 +1,11 @@ import nock from 'nock' import { Client } from '../../Client' import { CreateOfferRequest, OfferRequest } from './OfferRequestsTypes' -import { mockCreateOfferRequest, mockOfferRequest } from './mockOfferRequest' +import { + mockCreateOfferRequest, + mockItinerariesOfferRequest, + mockOfferRequest, +} from './mockOfferRequest' import { OfferRequests } from './OfferRequests' import { OfferPrivateFare } from '../../types' @@ -201,4 +205,66 @@ describe('OfferRequests', () => { expect(response.data.offers[0].private_fares).toHaveLength(1) expect(response.data.offers[0].private_fares[0].type).toBe('leisure') }) + + test('should forward `include_split_ticket` in the request body', async () => { + nock(/(.*)/) + .post(`/air/offer_requests/`, (body) => { + expect(body?.data?.include_split_ticket).toBe(true) + return true + }) + .reply(200, { data: mockOfferRequest }) + + const response = await new OfferRequests( + new Client({ token: 'mockToken' }), + ).create({ + ...mockCreateOfferRequest, + include_split_ticket: true, + }) + expect(response.data?.id).toBe(mockOfferRequest.id) + }) + + test('should forward `view=itineraries` as a query parameter and return the itineraries-view shape', async () => { + nock(/(.*)/) + .post(`/air/offer_requests/`) + .query({ view: 'itineraries' }) + .reply(200, { data: mockItinerariesOfferRequest }) + + const response = await new OfferRequests( + new Client({ token: 'mockToken' }), + ).create({ + ...mockCreateOfferRequest, + include_split_ticket: true, + view: 'itineraries', + }) + + const slice = response.data.slices[0] + expect(slice.itineraries).toHaveLength(1) + const brand = slice.itineraries[0].brands[0] + expect(brand.fare_brand_name).toBe('Economy Basic') + expect(brand.offers.map((offer) => offer.type)).toEqual([ + 'split_ticket', + 'single_ticket', + ]) + }) + + test('should not strip `include_split_ticket` from the body when `view` is omitted', async () => { + nock(/(.*)/) + .post(`/air/offer_requests/`, (body) => { + expect(body?.data?.include_split_ticket).toBe(true) + return true + }) + .query((queryObject) => { + expect(queryObject?.view).toBe(undefined) + return true + }) + .reply(200, { data: mockOfferRequest }) + + const response = await new OfferRequests( + new Client({ token: 'mockToken' }), + ).create({ + ...mockCreateOfferRequest, + include_split_ticket: true, + }) + expect(response.data?.id).toBe(mockOfferRequest.id) + }) }) diff --git a/src/booking/OfferRequests/OfferRequests.ts b/src/booking/OfferRequests/OfferRequests.ts index 6ba9828e..6fa2cd74 100644 --- a/src/booking/OfferRequests/OfferRequests.ts +++ b/src/booking/OfferRequests/OfferRequests.ts @@ -5,6 +5,7 @@ import { CreateOfferRequestQueryParameters, DuffelResponse, OfferRequest, + OfferRequestItinerariesView, PaginationMeta, } from '../../types' @@ -58,22 +59,31 @@ export class OfferRequests extends Resource { * To search for flights, you'll need to create an `offer request`. * An offer request describes the passengers and where and when they want to travel (in the form of a list of `slices`). * It may also include additional filters (e.g. a particular cabin to travel in). - * @param {Object} [options] - the parameters for making an offer requests (required: slices, passengers; optional: cabin_class, return_offers) + * @param {Object} [options] - the parameters for making an offer requests (required: slices, passengers; optional: cabin_class, return_offers, view, include_split_ticket) * When `return_offers` is set to `true`, the offer request resource returned will include all the `offers` returned by the airlines. * If set to false, the offer request resource won't include any `offers`. To retrieve the associated offers later, use the List Offers endpoint, specifying the `offer_request_id`. + * When `view` is set to `'itineraries'`, offers are grouped per slice into a hierarchy of itineraries and fare brands. + * Combine `view: 'itineraries'` with `include_split_ticket: true` to surface split-ticket candidates. * @link https://duffel.com/docs/api/offer-requests/create-offer-request + * @link https://duffel.com/docs/guides/selling-split-ticket-itineraries */ public create = async ( options: CreateOfferRequest & QueryParams, ): Promise< DuffelResponse< - // Ensure that the `offers` field can't be accessed if `return_offers` is false - QueryParams extends { return_offers: false } + // The response shape depends on the `view` query parameter. The + // `itineraries` view always returns the grouped representation and never + // includes a top-level `offers` list, so it takes precedence over the + // `return_offers` discriminator. + QueryParams extends { view: 'itineraries' } + ? OfferRequestItinerariesView + : // Ensure that the `offers` field can't be accessed if `return_offers` is false + QueryParams extends { return_offers: false } ? Omit : OfferRequest > > => { - const { return_offers, supplier_timeout, ...data } = options + const { return_offers, supplier_timeout, view, ...data } = options return this.request({ method: 'POST', @@ -84,6 +94,7 @@ export class OfferRequests extends Resource { return_offers !== null && { return_offers }), ...(supplier_timeout !== undefined && supplier_timeout !== null && { supplier_timeout }), + ...(view !== undefined && view !== null && { view }), }, }) } diff --git a/src/booking/OfferRequests/OfferRequestsTypes.ts b/src/booking/OfferRequests/OfferRequestsTypes.ts index 3776cdfb..7eb77a9c 100644 --- a/src/booking/OfferRequests/OfferRequestsTypes.ts +++ b/src/booking/OfferRequests/OfferRequestsTypes.ts @@ -5,7 +5,7 @@ import { Place, PlaceType, } from '../../types' -import { Offer } from '../Offers/OfferTypes' +import { Offer, OfferSliceSegment } from '../Offers/OfferTypes' export interface OfferRequestSlice { /** @@ -292,6 +292,17 @@ export interface CreateOfferRequest { * whereas return trips will need two. */ slices: CreateOfferRequestSlice[] + + /** + * When set to `true` and the offer request contains more than one slice, + * Duffel will fire additional one-way searches per slice to find + * split-ticket itinerary candidates. Split-ticket offers are only returned + * when combined with the `view=itineraries` query parameter. + * + * Requires this capability to be enabled on your Duffel account. + * @link https://duffel.com/docs/guides/selling-split-ticket-itineraries + */ + include_split_ticket?: boolean } export type TimeRangeFilter = { from: string; to: string } @@ -326,6 +337,19 @@ export interface CreateOfferRequestSlice { departure_time: TimeRangeFilter | null } +/** + * The shape of the offer request response. + * + * - `offers` (default) — a flat list of offers under `data.offers`. + * - `itineraries` — offers are grouped per slice into a hierarchy of + * itineraries and fare brands under `data.slices[].itineraries[]`. This is + * the view required to surface split-ticket candidates created with + * `include_split_ticket: true`. + * + * @link https://duffel.com/docs/guides/selling-split-ticket-itineraries + */ +export type OfferRequestView = 'offers' | 'itineraries' + export interface CreateOfferRequestQueryParameters { /** * When set to `true`, the offer request resource returned will include all the offers returned by the airlines. @@ -335,6 +359,15 @@ export interface CreateOfferRequestQueryParameters { */ return_offers?: boolean + /** + * Controls the shape of the response. Defaults to `offers`, which returns a + * flat list of offers. Set to `itineraries` to receive offers grouped by + * slice, itinerary and fare brand — required to retrieve split-ticket + * candidates produced by `include_split_ticket: true`. + * @link https://duffel.com/docs/guides/selling-split-ticket-itineraries + */ + view?: OfferRequestView + /** * The maximum amount of time in milliseconds to wait for each airline search to complete. * This timeout applies to the response time of the call to the airline and includes @@ -349,3 +382,109 @@ export interface CreateOfferRequestQueryParameters { */ supplier_timeout?: number } + +/** + * Discriminator for offers returned under the `itineraries` view. + * + * - `single_ticket` — a single offer from one airline that covers every slice + * in the original offer request. + * - `split_ticket` — an offer that covers a single slice, intended to be + * combined with offers for the remaining slices (potentially from a + * different airline) to fulfil the journey. + * + * @link https://duffel.com/docs/guides/selling-split-ticket-itineraries + */ +export type ItineraryOfferType = 'single_ticket' | 'split_ticket' + +/** + * An offer returned under the `itineraries` view. + * + * It is structurally the same as a regular {@link Offer} (minus + * `available_services`, which is only populated by the Get single offer + * endpoint) but carries a `type` discriminator describing whether the offer + * covers the full journey or a single slice that needs to be combined with + * other offers. + */ +export interface ItineraryOffer extends Omit { + /** + * Whether the offer covers every slice from a single airline + * (`single_ticket`) or only the slice it is nested under and needs to be + * combined with other offers to complete the journey (`split_ticket`). + */ + type: ItineraryOfferType +} + +/** + * A fare brand grouping offers that share the same itinerary segments. + */ +export interface ItineraryBrand { + /** + * The airline's marketing name for the fare brand, e.g. "Economy Basic". + */ + fare_brand_name: string + + /** + * The offers available for this fare brand on this itinerary. + */ + offers: ItineraryOffer[] +} + +/** + * One way the airline can fly a passenger from the slice's origin to its + * destination. A single itinerary is a fixed list of segments which may be + * sold under one or more fare brands. + */ +export interface Itinerary { + /** + * The segments that make up this itinerary, in the order they are flown. + */ + segments: OfferSliceSegment[] + + /** + * The fare brands available for this itinerary, each carrying one or more + * bookable offers. + */ + brands: ItineraryBrand[] +} + +/** + * A slice as represented in the `itineraries` view of an offer request. + * + * Unlike {@link OfferRequestSlice}, it does not include the `departure_date` + * or origin/destination type fields directly — the per-segment scheduling + * lives inside `itineraries[].segments[]`. + */ +export interface OfferRequestItinerariesViewSlice { + /** + * The city or airport the passengers want to depart from. + */ + origin: Place + + /** + * The city or airport the passengers want to travel to. + */ + destination: Place + + /** + * The itineraries available for this slice, each grouping offers by fare + * brand. + */ + itineraries: Itinerary[] +} + +/** + * The response payload returned when an offer request is created with the + * `view=itineraries` query parameter. Offers are grouped per slice rather than + * returned as a flat list, which is required to surface split-ticket + * candidates produced by `include_split_ticket: true`. + * + * @link https://duffel.com/docs/guides/selling-split-ticket-itineraries + */ +export interface OfferRequestItinerariesView + extends Omit { + /** + * The slices that make up this offer request, each carrying the itineraries + * and offers available for that slice. + */ + slices: OfferRequestItinerariesViewSlice[] +} diff --git a/src/booking/OfferRequests/mockOfferRequest.ts b/src/booking/OfferRequests/mockOfferRequest.ts index 4c8ee851..8f7e7254 100644 --- a/src/booking/OfferRequests/mockOfferRequest.ts +++ b/src/booking/OfferRequests/mockOfferRequest.ts @@ -1,5 +1,10 @@ import { mockOffer } from '../Offers/mockOffer' -import { CreateOfferRequest, OfferRequest } from '../../types' +import { + CreateOfferRequest, + ItineraryOffer, + OfferRequest, + OfferRequestItinerariesView, +} from '../../types' export const mockCreateOfferRequest: CreateOfferRequest = { slices: [ @@ -84,3 +89,42 @@ export const mockOfferRequest: OfferRequest = { created_at: '2020-02-12T15:21:01.927Z', cabin_class: 'economy', } + +const { available_services: _availableServices, ...mockOfferWithoutServices } = + mockOffer + +const mockSplitTicketOffer: ItineraryOffer = { + ...mockOfferWithoutServices, + id: 'off_00009htYpSCXrwaB9Dn456', + type: 'split_ticket', +} + +const mockSingleTicketOffer: ItineraryOffer = { + ...mockOfferWithoutServices, + type: 'single_ticket', +} + +export const mockItinerariesOfferRequest: OfferRequestItinerariesView = { + id: mockOfferRequest.id, + created_at: mockOfferRequest.created_at, + live_mode: mockOfferRequest.live_mode, + cabin_class: mockOfferRequest.cabin_class, + passengers: mockOfferRequest.passengers, + slices: [ + { + origin: mockOfferRequest.slices[0].origin, + destination: mockOfferRequest.slices[0].destination, + itineraries: [ + { + segments: mockOffer.slices[0].segments, + brands: [ + { + fare_brand_name: 'Economy Basic', + offers: [mockSplitTicketOffer, mockSingleTicketOffer], + }, + ], + }, + ], + }, + ], +}