Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion src/booking/OfferRequests/OfferRequests.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
})
})
19 changes: 15 additions & 4 deletions src/booking/OfferRequests/OfferRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CreateOfferRequestQueryParameters,
DuffelResponse,
OfferRequest,
OfferRequestItinerariesView,
PaginationMeta,
} from '../../types'

Expand Down Expand Up @@ -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 <QueryParams extends CreateOfferRequestQueryParameters>(
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, 'offers'>
: OfferRequest
>
> => {
const { return_offers, supplier_timeout, ...data } = options
const { return_offers, supplier_timeout, view, ...data } = options

return this.request({
method: 'POST',
Expand All @@ -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 }),
},
})
}
Expand Down
141 changes: 140 additions & 1 deletion src/booking/OfferRequests/OfferRequestsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
Place,
PlaceType,
} from '../../types'
import { Offer } from '../Offers/OfferTypes'
import { Offer, OfferSliceSegment } from '../Offers/OfferTypes'

export interface OfferRequestSlice {
/**
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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<Offer, 'available_services'> {
/**
* 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<OfferRequest, 'slices' | 'offers'> {
/**
* The slices that make up this offer request, each carrying the itineraries
* and offers available for that slice.
*/
slices: OfferRequestItinerariesViewSlice[]
}
46 changes: 45 additions & 1 deletion src/booking/OfferRequests/mockOfferRequest.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand Down Expand Up @@ -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],
},
],
},
],
},
],
}
Loading