Skip to content

Checkout API

Multi-step checkout flow for booking tour packages. Handles session management, selection updates, payment processing, and booking finalization.

The checkout API provides a stateful multi-step flow:

  1. Start session - Initialize checkout with offer data
  2. Select flights - Choose outbound/inbound flights (economy or business)
  3. Select hotels - Optional hotel upgrades
  4. Select activities - Optional activity add-ons
  5. Select transfers - Optional transfer upgrades, plus optional travel insurance
  6. Enter contact - Booking contact person (client data)
  7. Enter travelers - Passenger personal data
  8. Payment - Process deposit via Stripe
  9. Confirmation - View booking summary

Quotation branch (non-standard occupancy): Bookings where actual_pax_count != 2 OR a non-default room configuration is selected (session['is_non_standard_pax'] === true) cannot be self-served, because suppliers only grant allotment for the 2A baseline. These stop after the Main Contact step (PUT /checkout/contact): the booking transitions to quotation_requested, a Volare agent confirms availability with the DMC and moves it to quotation_confirmed, then invites the customer back via a continuation link to finish at the Travelers + Payment steps. See Quotation Flow.

Session storage: Server-side PHP session (checkout key)

Selection persistence: The frontend preserves user selections (flights, hotels, activities, transfers) when navigating back through checkout steps, so users don’t lose their choices.

Funnel tracking: A checkout-flow booking is created when checkout starts (initial status: checkout), tracking which step each user reaches. See Checkout Funnel Tracking for details.

Base URL: /api/{market}/{lang}/checkout

Locale-aware content: Hotel, activity, and transfer descriptions, names, and amenities are returned in the market’s locale when translations exist. The ResolveMarket middleware resolves the locale from the URL path and checkout services use $entity->translated('field', $locale) with automatic fallback to the source language. See Supplier Entity Translations for details.

Offer summary fields for GA4: Every option-fetching response (flight options, business flights, hotels, activities, transfers, contact, travelers) includes pbm_sku, country_name, region_name, and country_code on the offer summary. These are the geographic and identity fields the frontend GA4 funnel needs to keep item_id, item_category2/3, item_list_name, and location_id consistent across begin_checkoutpurchase. Resolved via ProductByMarket::getPrimaryCountryName(), getPrimaryRegionName(), and getPrimaryCountryIsoCode(). See GA4 Ecommerce Tracking.

POST /checkout/{offerId} (start - always creates fresh session)
├─► POST /checkout/{offerId}/search-economy-flights
│ (live re-pricing during loading screen)
├─► GET /checkout (view session, auto-refreshes price)
├─► PUT /checkout/flights
├─► PUT /checkout/hotels
├─► PUT /checkout/activities
├─► PUT /checkout/transfers
├─► PUT /checkout/insurance-selection
├─► PUT /checkout/contact
│ │
│ ├─ standard 2A: advance to Travelers
│ └─ non-standard pax: requestQuotation()
│ → status quotation_requested (STOP)
│ → agent confirms DMC → quotation_confirmed
│ → customer returns via continuation link
│ → POST /checkout/{offerId}/resume (rehydrate session)
├─► PUT /checkout/travelers
├─► POST /checkout/payment/intent
├─► POST /checkout/payment/confirm
└─► GET /checkout/confirmation/{reference}

Start checkout session for an offer. Always creates a fresh session, discarding any previous one. Creates a checkout booking via BookingFunnelService::createDraft() (status checkout) for funnel tracking and returns the booking_id in the response.

Bookability check: The offer must pass the bookable() scope (Active status AND departure date >= today + 5 days). If the offer is Active but too close to departure, returns 410 Gone with error: "offer_expired". If the offer doesn’t exist at all, returns 404. See Bookability for details.

Request (optional):

{
"actual_pax_count": 3,
"actual_room_type": "2A+1CH"
}

Request validation:

  • actual_pax_count (optional): 1-4 passengers, defaults to offer’s pax_count
  • actual_room_type (optional): Must exist in supplier_service_rate_prices for the tour’s base hotels, defaults to room type matching pax count

If actual_room_type is unavailable for the tour’s base hotels, returns 422 Unprocessable Entity with error room_type_unavailable. This per-hotel check is skipped for package tours (tours with linked package services via SupplierTour::hasPackageServices()): the package’s flat base pricing already covers hotel inclusions, so per-selection-hotel room-type rows are not required.

Pricing for non-standard pax (not 2A):

When actual_pax_count differs from the offer’s 2A default:

  1. Land price is recalculated using AutoOfferGeneratorService::calculateLandPrice() for the actual room type
  2. Flight price scales linearly (offer’s per-person flight price × actual_pax_count)
  3. Per-pax marketing rounding applied: rawPerPax → roundToMarketingPrice → multiply by paxCount
  4. Session stores is_non_standard_pax: true to control price refresh behavior

Response: 201 Created

{
"success": true,
"data": {
"offer_id": 123,
"booking_id": 456,
"booking_reference": "BK-A1B2C3D4",
"started_at": "2026-02-15T10:30:00Z",
"base_price": 2499.00,
"extras_price": 0.00,
"total_price": 2499.00,
"pax_count": 2,
"actual_pax_count": 3,
"actual_room_type": "2A+1CH",
"is_non_standard_pax": true,
"currency": { "code": "EUR", "symbol": "EUR" }
}
}

After receiving the response, the TripConfigurator stores booking_id in Astro.session via POST /api/checkout/session for SSR step tracking.

Get current session state. Returns 404 if no active session.

Auto-refreshes base_price and total_price if the offer’s final_price has changed since the session was created (e.g., after a live economy flight search updated the price via EconomyFlightCacheUpdateService).

Important: For non-standard pax sessions (is_non_standard_pax: true), the base_price is NOT refreshed from the offer’s 2A-based final_price. The non-standard quote is computed once in CheckoutSessionService::start() and stored on the booking; the live economy/business search never re-derives it. The booking’s stored quote is the single source of truth.

Return the offer’s available economy flight options for the FlightSelector, including the canonical identity of the option the backend is currently bound to.

Response shape (relevant fields):

{
"success": true,
"data": {
"offer_id": 123,
"source_type": "cache",
"has_flights": true,
"outbound_options": [
{ "signature": "IB1581+LP2387", "...": "..." }
],
"inbound_options": [
{ "signature": "LP2344", "...": "..." }
],
"cabinClass": "ECONOMY",
"baggageIncluded": { "checkedBag": true, "weight": 23 },
"bound_flight_signature": "IB1581+LP2387|LP2344"
}
}
FieldTypeDescription
bound_flight_signaturestringRoot-level canonical identity of the offer’s currently-bound round-trip option. Composed as outbound|inbound from the bound cache row’s PRIMARY itinerary per leg (OfferFlightSignature::forBoundOffer). Empty string when the offer has no resolvable international binding (draft, manual flight, missing cache row) — the FE then falls back to “recommended” / first option.
outbound_options[].signaturestringPer-leg OPERATOR+FLIGHTNUMBER (joined by + for connections), built from the live FlightSegment DTOs via OfferFlightSignature::fromFlightSegments.
inbound_options[].signaturestringSame shape as outbound_options[].signature.

FE reconciler invariant. The frontend mirrors the user’s UI selection to bound_flight_signature unless the user has explicitly picked a different option AND that option still exists in the refreshed list. This prevents the worst-duration card from staying “Seleccionado” after a background live-search auto-upgrade rebinds the offer to a better flight.

Source: backend/app/Http/Resources/CheckoutFlightOptionsResource.php, backend/app/Services/Flights/Domain/OfferFlightSignature.php

POST /checkout/{offerId}/search-economy-flights

Section titled “POST /checkout/{offerId}/search-economy-flights”

Trigger a live economy flight search during the checkout loading screen. Calls the Aerticket API to re-price the offer’s international and domestic flights before the user proceeds.

Rate limit: 5 requests per minute.

Behavior:

  1. Extracts route info (departure, destination, dates) from the offer’s cached flight segments
  2. Applies admin-configured search filters from Settings page (airlines, max stops, fare sources, baggage, latest arrival time) with forced ECONOMY cabin class
  3. Detects multi-city vs round-trip based on whether inbound departure differs from outbound destination
  4. Searches domestic flight legs (one-way per leg) alongside international — domestic failures are graceful with cached prices as fallback
  5. Selects the best fare under FlightRankingPolicy: hard filters first (baggage on both international legs, max stops, layover, return departure, departure window, nights), then the best available stop-count tier, then total round-trip duration, price, stops, and outbound-arrival tiebreaks. Economy live search orders /search results first and allows at most one /search-upsell lookup for the best unresolved baggage candidate. “Seleccionado” is the live policy winner; the previous bound fare is not pinned if it is worse or violates policy. The frontend FlightSelector exposes a client-side “Ordenar por” toggle defaulting to duration (preserves API order) with a price option that re-sorts by ascending totalExtraPrice
  6. Updates DynamicFlightCache with fresh results (top 5 fares) for both international and domestic legs
  7. Re-links the offer’s OfferFlight records and updates individual leg prices
  8. Recalculates the offer’s final_price (international + domestic total)
  9. Stores economy search metadata in session for building the per-leg breakdown during flight selection: international_fare_price (from the selected fare’s totalPrice), domestic legs enriched with searched_price (live API price when available), and international route info

Standard 2A vs non-standard pax: Steps 6–8 (cache update, offer re-link, final_price recalculation via updateCacheAndOfferPrice()) run only when actual_pax_count === 2. For non-standard occupancy the offer and its shared 2A cache are never mutated — EconomyFlightSearchService::buildInternationalSelection() picks a display fare only (skipping refreshSignatureMatchedRows and bindFirstDisplayFare), and the search refreshes flight display/availability without repricing. The price computed at CheckoutSessionService::start() remains the quote.

Response (success):

{
"success": true,
"data": {
"has_flights": true,
"price_changed": true,
"original_price": 2499.00,
"new_price": 2549.00
}
}

Note: For non-standard pax, no repricing occurs: price_changed is false and original_price/new_price both equal the offer’s existing final_price. The stored booking quote is unaffected.

Response (fallback — API timeout or error):

{
"success": true,
"data": {
"has_flights": true,
"fallback": true,
"price_changed": false
}
}

On fallback, the checkout continues with existing cached flight data. The cache update failure is non-fatal.

Search for business class flight options during checkout. Performs a LIVE search via the Aerticket API and calculates final prices including margin.

Requirements: Must have an active checkout session (protected by stateful.api middleware).

Behavior:

  1. Extracts route from offer’s cached flight segments (including inbound departure/destination for multi-city detection)
  2. Applies admin-configured search filters from Settings page via SettingsService::buildCheckoutSearchOptions() (airlines, max stops, fare sources, baggage) with forced BUSINESS cabin class
  3. Detects multi-city vs round-trip: multi-city when inbound departure differs from outbound destination
  4. Performs live Aerticket search using session’s actual_pax_count
  5. Searches domestic flight legs (one-way BUSINESS per leg) alongside international — if no business fares found for a domestic leg, falls back to cached economy price (already available from the economy search that runs first)
  6. Domestic total is added to every business fare card’s “+x€” extra price calculation. The BUSINESS tab itself no longer surfaces an aggregate price delta on the cabin-class switcher (operator rule #7: business price is shown per-card on the selector, not as a tab badge). Fares are ranked by FlightRankingPolicy (same hard filters, then duration → price → stops → arrival; no Pareto prune) and capped to the top 10. Per-card durations are computed via LegDuration::forLeg using IANA airport timezones — the previous segment-sum fallback was incorrect for long-haul wallclock segments and ignored layovers
  7. Business flights are fetched on demand when the user switches to the BUSINESS tab. The previous background prefetch (useEffect in CheckoutPage.tsx that fired on every checkout open) has been removed so the live Aerticket call only happens when the customer actually opts into the upgrade
  8. Skips incomplete fares that do not contain both outbound and inbound legs
  9. Returns fareId on each outbound/inbound leg option so frontend can pair legs by fare identity (not by array index)
  10. Stores per-leg search metadata in session (legs_metadata) for later use when user selects a business fare — includes domestic leg details, fare-to-price mapping, fare details keyed by fare ID (fare_price, total_extra_price, outbound/inbound flight numbers), and international route info. The total_extra_price per fare is used during flight selection to compute business_extra_price_per_person server-side (see PUT /checkout/flights).
  11. For non-standard pax, passes session context (actual_pax_count, actual_room_type, base_price) to service
  12. Calculates prices:
  • Standard 2-pax: Uses offer’s land_base_price and final_price directly
  • Non-standard pax: Recalculates land price for actual room type, applies per-pax marketing rounding

Response:

{
"success": true,
"data": {
"outbound_options": [...],
"inbound_options": [...],
"cabinClass": "BUSINESS",
"has_flights": true,
"source_type": "live_search",
"original_final_price": 2499.00,
"pax_count": 3,
"apihubflowid": "..."
}
}

Source: backend/app/Services/Checkout/BusinessFlightSearchService.php

Update flight selection.

Request:

{
"cabin_class": "ECONOMY",
"outbound": {
"departure_date": "2026-02-15",
"departure_time": "09:10",
"arrival_time": "15:15",
"departure_airport": "MAD",
"arrival_airport": "NBO",
"flight_numbers": ["EK123", "EK456"],
"airlines": [{"code": "EK", "name": "Emirates"}],
"stops": 1,
"stopover_airports": ["DXB"],
"arrivalDayOffset": 1
},
"inbound": { /* same structure */ },
"business_extra_price_per_person": null,
"fare_id": null
}

Validation: See UpdateFlightSelectionRequest for full rules. Key constraint: fare_id is required when cabin_class=BUSINESS (returns 400 if missing).

Server-side price validation (business class):

When cabin_class=BUSINESS, the backend validates and computes the upgrade price server-side instead of trusting the client-provided business_extra_price_per_person. This prevents price manipulation (e.g., sending business_extra_price_per_person: 0 to get a free upgrade).

The validation chain in enrichWithLegsBreakdown():

  1. Rejects if business_search_metadata is missing from the session (no prior business search)
  2. Rejects if fare_id is empty
  3. Rejects if fare_id is not found in the stored fare_details metadata (stale or unknown fare)
  4. Rejects if total_extra_price is missing from the fare details
  5. Overrides business_extra_price_per_person with: total_extra_price / paxCount (server-computed)

All rejections return 422 Unprocessable Entity with descriptive validation messages prompting the user to search again.

The fare_id also canonicalizes outbound/inbound flight numbers from server-side search metadata, preventing mismatches when multiple fares share the same outbound leg.

Source: backend/app/Services/Checkout/CheckoutSessionService.php (enrichWithLegsBreakdown)

Get available hotel options for an offer, grouped by time period. Returns all 3 tiers: Selection (base, included in price), Luxury (upgrade), and Grand Luxury (upgrade).

Response shape per hotel entry:

FieldTypeDescription
idstringUnique key: {hotelId}-{tier}-{startDay}-{endDay}
hotelIdnumber|nullSupplier hotel ID (null for package fallback entries)
namestringHotel name (or package name for legacy fallback)
locationstring|nullCity (null for package fallback entries)
tierstringselection, luxury, or grand_luxury
tier_labelstringHuman-readable tier label
nightsobject{ start: number, end: number }
imageUrlstring|nullFirst hotel image URL (legacy single-image field, retained for backward compatibility)
imageUrlsstring[]All hotel image URLs in admin-configured order. Empty array when the hotel has no images (including package fallback entries, which always return [] since they have no underlying SupplierHotel)
descriptionstring|nullHotel description text from SupplierHotel
isIncludedbooleantrue for Selection tier, false for upgrades
priceDifferencenumber|nullPrice difference vs Selection tier, null for Selection
selectionHotelNamestring|nullBase hotel name (for upgrades), null for Selection

imageUrls is additiveimageUrl still returns the first image for existing clients. This endpoint uses camelCase throughout (matching imageUrl, hotelId, isIncluded); the sibling /checkout/{offerId}/activities endpoint uses snake_case (image_url, image_urls). Each endpoint matches its own pre-existing naming convention rather than unifying across endpoints.

Hotels are grouped by consecutive nights at the same Selection hotel. Each group contains one Selection entry and optional Luxury/Grand Luxury upgrade entries. Selection-only days (no upgrades available) are included as standalone entries.

Package tours: When a package tour has selection hotels assigned in the admin, the API returns actual hotel names, images, and locations — same as non-package tours. Only legacy package tours without selection hotels fall back to using the package service name as a placeholder.

Room type pricing: Hotel upgrade price differences use the session’s actual_room_type (e.g., 3A for 3 travelers). The room_type query parameter overrides the session value if provided. Hotels without pricing for the selected room type return priceDifference: null and are shown as unavailable (non-selectable in the frontend).

Query parameters:

ParamTypeDescription
room_typestring (optional)Room type for pricing: 1A, 2A, 3A, or 4A. Falls back to session’s actual_room_type, then to 2A.

Source: backend/app/Services/Checkout/CheckoutHotelService.php

Update hotel upgrade selections. Supports Luxury and Grand Luxury tier upgrades.

Request:

{
"advance": true,
"hotel_selections": [
{
"upgrade_hotel_id": 6,
"nights_start": 1,
"nights_end": 3
}
]
}

Notes:

  • advance (optional, default true): When true, calls advanceStep to record funnel progression. Frontend sends false on backward navigation so the funnel step is not advanced.
  • upgrade_hotel_id refers to a Luxury or Grand Luxury tier hotel
  • price_difference is calculated server-side (not accepted from client)
  • See Service Tiers for tier details

Update activity upsell selections. Prices calculated server-side.

Request:

{
"advance": true,
"activity_selections": [
{ "activity_id": 5, "day_number": 2 }
]
}
  • advance (optional, default true): When false, skips advanceStep (used on backward navigation).

Interval-overlap validation: The customer’s selected extras must not time-overlap each other on the same day. Each selected activity occupies the interval [start_time, start_time + duration_hours) (both fields are exposed per upgrade on the GET /checkout/{offerId}/activities response). Two selections conflict only when their intervals strictly overlap — touching endpoints (one ends exactly when the next starts) never conflict, and no buffer is required between them. Activities without a parseable start_time (“anytime”) or without a positive duration never participate and can always be added; a full-day extra naturally overlaps everything else that day. The check compares selected extras against each other only — included activities are never involved, as the supplier guarantees they fit (see Time Slots).

Enforced by ActivityScheduleOverlapValidator, on by default. The frontend opts out via validate_schedule: false only on backward navigation (the “previous” button), so going back is never blocked by a 400. Committing saves (next, and save-and-return when editing from the summary) and any direct API call validate by default — note this is independent of advance, since save-and-return sends advance: false but must still validate. On conflict the endpoint returns 400 with error: "activity_time_conflict" and an errors array of per-day messages. The frontend also blocks overlapping selections client-side; this is the defense-in-depth backstop.

  • validate_schedule (optional, default true): When false, skips the overlap check. The frontend sends false only on backward navigation so a partially-overlapping draft can be parked without a 400.

Update transfer selections. Prices calculated server-side.

Request:

{
"advance": true,
"transfer_selections": [
{ "transfer_id": 1, "day_number": 1 }
]
}
  • advance (optional, default true): When false, skips advanceStep (used on backward navigation).

Persist (or clear) the chosen travel insurance policy in the checkout session. Offered on the transfers (extras) step. Send insurance as null (or omit it) to deselect. Recalculates extras_price/total_price and returns the updated CheckoutSessionResource.

Unlike other extras, the price IS accepted from the client: insurance is priced by a live Intermundial quote (no local catalogue to recalculate from). The retail_price is the whole-party total and is added as-is.

Request:

{
"insurance": {
"supplier_insurance_id": 1,
"policy_id_dyn": 24319,
"price_list_params_values_1_id_dyn": 1,
"price_list_params_values_2_id_dyn": 1,
"base_prices_id_dyn": 5,
"effect_date": "2026-06-15",
"unsuscribe_date": "2026-06-25",
"retail_price": 89.0,
"product_name": "Multitravel",
"currency": "EUR"
}
}

Validation: See UpdateInsuranceSelectionRequest.

The dedicated insurance integration (policies, live quote, post-payment emission) is documented in Travel Insurance (Intermundial).

Get offer summary for the client contact data entry page. Returns the same offer metadata as the travelers endpoint.

Response: 200 OK — Same structure as GET /checkout/{offerId}/travelers.

Errors:

  • 404 - Offer not found, not bookable (expired or within lead time), or belongs to different market

Source: backend/app/Http/Controllers/Api/CheckoutController.php

Store client contact data (the booking contact person). Separate from traveler data — the client is the person responsible for the booking, not necessarily a traveler.

The saved client_data is also reused by the frontend traveler step to prefill traveler #1 (name, email, phone) when the user advances to /travelers. This is a UX convenience only: traveler consent and passport / nationality fields must still be entered explicitly on the traveler step.

Request:

{
"client": {
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+34612345678"
}
}

Validation: See StoreClientDataRequest for rules. No ASCII-only restriction on names (client data is not sent to airline APIs).

FieldTypeRequiredDescription
first_namestringYesFirst name (max 100 chars)
last_namestringNoLast name (optional; min 2 chars when provided, max 100)
emailstringYesValid email (max 255 chars)
phonestringYesPhone number (max 30 chars)

Pricing: Client data does NOT affect pricing.

Quotation branch: When the session has is_non_standard_pax === true, this endpoint does NOT advance to Travelers. Instead it calls BookingFunnelService::requestQuotation(), which snapshots the full session to booking.checkout_session_data, extends expires_at by booking.expiration.quotation_days (30), transitions Checkout → QuotationRequested, and fires QuotationRequestedNotification. The funnel pointer stays at Main Contact. See Quotation Flow.

Errors:

  • 400 - No checkout session (error: "no_checkout_session")
  • 400 - Validation error

Rehydrate a confirmed quotation’s checkout session from its stored snapshot, so a non-standard-pax customer can finish checkout days later with the originally quoted price intact (no fresh flight search). Throttled 10/min, under the stateful.api group.

Request:

{ "token": "<booking.resumption_token>" }

Finds the booking by resumption_token + offer_id, requires status QuotationConfirmed, then restores the session via CheckoutSessionService::restore() (re-binding booking_id) and returns the session resource.

Errors:

CodeErrorDescription
400missing_tokenNo token in request body
404invalid_tokenNo booking matches the token + offer
409not_resumableBooking is not in quotation_confirmed (e.g. still pending, or already paid)
410snapshot_missingBooking has no checkout_session_data snapshot to restore

Source: backend/app/Http/Controllers/Api/CheckoutController.php (resumeQuotation)

Store traveler personal data. Count must match offer’s pax_count.

Name validation: Names are validated using the same PassengerValidationRules as the Filament admin panel, enforcing AerTicket API requirements to prevent booking failures:

  • ASCII letters and spaces only (/^[a-zA-Z\s]+$/) — no accents, numbers, or symbols
  • No + character
  • Last name minimum 2 characters
  • Each name maximum 57 characters
  • Combined first + last name: 2-57 characters total

Validation source: StoreTravelerDataRequest reuses PassengerValidationRules::firstName() and ::lastName().

Request:

{
"travelers": [
{
"first_name": "John",
"last_name": "Doe",
"nationality": "ES",
"birth_date": "1990-05-15",
"phone": "+34612345678",
"email": "john@example.com",
"passport_number": "AB1234567",
"passport_expiry": "2030-01-01"
}
]
}

Get booking data for confirmation page after successful payment.

duration_days is the door-to-door trip length derived from the bound international flight via Offer::getTravelDates() — the same source as the trip page and booking emails — counting from the outbound departure day to the return arrival-home day, inclusive. duration_nights is duration_days - 1. When no flight is bound, both fall back to the product’s land-tour duration.

Response:

{
"success": true,
"data": {
"booking_reference": "VOL-2026-00001",
"tour_name": "Aventura en Japon",
"duration_days": 10,
"duration_nights": 9,
"travelers": 2,
"hero_image": "https://cdn.example.com/japan.jpg",
"trip_url": "https://..."
}
}

Prices update automatically as selections change:

total_price = base_price + extras_price
extras_price = sum of:
+ business_extra_price_per_person * actual_pax_count
+ hotel_price_differences (uses session's actual_room_type)
+ activity_prices * actual_pax_count
+ transfer_prices
+ insurance_retail_price (whole-party total, added as-is)

Security: All upgrade prices are calculated server-side. Activity and transfer prices come from the database. Business class business_extra_price_per_person is computed from server-stored total_extra_price metadata (see PUT /checkout/flights). Client-provided prices are ignored for all extras except insurance, whose retail_price comes from a live Intermundial quote and is added as-is (see Travel Insurance).

Variable pax count: The actual_pax_count from the session is used for flight and activity extras calculation. Hotel upgrade pricing uses the session’s actual_room_type to match the selected passenger configuration.

Bookings with actual_pax_count != 2 OR a non-default room configuration set session['is_non_standard_pax'] === true. Suppliers only grant allotment for the 2A baseline, so these cannot be paid self-service and follow a quotation-first flow:

  1. Customer completes checkout up to Main Contact (PUT /checkout/contact).
  2. BookingFunnelService::requestQuotation() snapshots the session to booking.checkout_session_data, extends expires_at (quotation_days, 30), and transitions Checkout → QuotationRequested. The funnel does NOT advance to Travelers.
  3. QuotationRequestedNotification alerts the reservations inbox (mail) and sales agents/admins (Arkana bell). A Volare agent confirms availability with the DMC.
  4. Agent moves the booking to QuotationConfirmed and sends CheckoutContinuationNotification to the customer, whose CTA links to Booking::getCheckoutContinuationUrl() ({frontend}/{market}/checkout/{offerId}/travelers?resume={resumption_token}).
  5. The frontend calls POST /checkout/{offerId}/resume, which rehydrates the snapshotted session. The customer finishes at Travelers + Payment with the originally quoted price.

Payment gate: PaymentController::createIntent only promotes bookings in Draft | Checkout | QuotationConfirmed. A QuotationRequested booking cannot be paid until an agent confirms.

Session resource fields (CheckoutSessionResource): exposes is_non_standard_pax and requires_quotation booleans, display fields product_title, product_country, departure_date, departure_airport, volare_phone, plus computed price_per_person and base_price_per_person (the frontend renders per-person figures from these instead of dividing the total).

Related notifications: See Quotation Notifications.

See Payment Gateway System for full payment documentation.

Key points:

  1. createIntent uses session’s total_price (including extras) for deposit calculation
  2. confirm creates/updates Client from client_data (not traveler_data[0]), then calls BookingFinalizationService::finalizeFromCheckoutSession()
  3. Booking stores payment_method_code and payment_gateway_code for operational tracking
  4. Status is routed via PaymentService::updateBookingPaymentStatus():
    • Flight booking (ECONOMY / BUSINESS) -> pending_flight_booking
    • Land-only booking -> pending_land_confirmation
  5. Session cleanup: After successful payment (PaymentStatus::Succeeded), the checkout session is cleared via CheckoutSessionService::clear() to prevent session reuse
  6. After payment, redirect to /confirmation/{reference}

After a successful checkout payment, flight bookings move to pending_flight_booking and are dispatched manually by admins from the booking view page. Each flight leg is booked independently with its own FlightBooking record.

Trigger condition: booking status is pending_flight_booking (or retry from flight_booking_failed).

Process:

  1. Admin clicks Book All Flights / Book Flight (or Retry All Flights / Retry) in Filament
  2. Booking transitions to flight_booking_in_progress
  3. Action computes which legs are still unbooked (skips legs with existing FlightBooking records)
  4. Dispatches one CreateCheckoutFlightBookingJob per unbooked leg to aerticket-bookings queue (each with $legIndex parameter)
  5. Each job independently:
    • International legs: Re-searches via EconomyFlightSearchService::searchInternationalOnly() (economy) or BusinessFlightSearchService::searchBusinessFlightsRaw() (business), matches round-trip by flight numbers + price
    • Domestic legs: Searches one-way via AerticketSearchService, matches by flight numbers + price
  6. Verifies availability with Aerticket
  7. Creates booking via AerticketBookService
  8. Creates FlightBooking record linked to Booking via booking_id FK with leg_index and flight_type
  9. Dispatches AerticketRetrieveBookingJob to fetch PNR details
  10. All-legs-booked check: Only transitions to flights_confirmed when ALL legs have FlightBooking records. Handles concurrent transition race with try/catch on InvalidArgumentException.
  • Partial success -> admin notification “Leg N booked (M/N legs)”
  • All legs booked -> flights_confirmed
  • Failure -> flight_booking_failed (with error metadata including leg_index)

Business class specifics:

  • Business fares are bundled (1 fare = both legs, itinerary index 1 only), unlike economy mix-and-match
  • BookingUpsell with type flight_upgrade continues to be created for financial tracking

Idempotency: Per-leg check via FlightBooking::where(booking_id, leg_index)->exists() with row-level lock. The job implements ShouldBeUnique with uniqueId = "checkout-flight:{bookingId}:{legIndex}".

Error handling:

Retry behavior depends on the failure type, classified by CheckoutFlightBookingFailure enum:

  • noMatchingFare (fare no longer available): Released back to queue after 300 seconds (5 minutes) to allow fare inventory to refresh. If still failing on second attempt, the job is explicitly failed. This delay gives the airline fare cache time to update.
  • Other failures (searchFailed, verifyFailed, bookingFailed): Standard retry with 120 seconds backoff via framework $backoff property.

Job configuration: $tries = 3, $maxExceptions = 2, $timeout = 420, $backoff = [120], $uniqueFor = 480.

  • Admin users receive Filament database notifications on success or failure (per-leg and all-legs-complete)
  • Failed bookings can be retried from booking view actions (top-level or per-leg)

Structured error context:

CheckoutFlightBookingException carries a context array with structured details about WHY a booking failed. This context is spread into the booking_status_transitions.metadata JSON on failure, making error details visible to admins in the status timeline.

Context is automatically extracted from Aerticket exception types via extractContext():

Aerticket ExceptionContext Fields
AerticketPriceChangeExceptionsub_type, original_price, new_price, currency, percentage_change
AerticketFareExpiredExceptionsub_type, fare_id, expired_at
AerticketTimeoutExceptionsub_type (timeout)
AerticketValidationExceptionsub_type, validation_errors
AerticketVerifyExceptionsub_type, fare_id
AerticketBookingExceptionsub_type, fare_id

API error responses (verify/book returning provider errors) include sub_type: api_error and provider_errors array.

The noMatchingFare factory also accepts context with flight_numbers, cabin_class, total_fares_searched, expected_price, and leg_type/leg_index for debugging fare matching failures.

The status timeline Blade component displays the message field from failed transition metadata directly under the status badge.

Matching logic:

  • International (economy and business): Per-leg fare_price * pax_count must match fare’s totalPrice (within 0.01 precision, for 2-pax only) AND outbound/inbound flight numbers must match exactly. Non-2-pax bookings match by flight numbers only. fare_price is always stored per-person in flight_selection.legs.
  • Domestic: One-way search, matched by flight numbers + price (fare_price * pax_count for 2-pax). findMatchingOneWayFare() matches on outbound leg only.
  • If no matching fare found, job releases with 5-minute delay before failing

Source:

  • Job: backend/app/Jobs/CreateCheckoutFlightBookingJob.php
  • Exception: backend/app/Exceptions/CheckoutFlightBookingException.php
  • Failure enum: backend/app/Enums/CheckoutFlightBookingFailure.php
  • Service: backend/app/Services/Checkout/CheckoutFlightBookingService.php
  • Status transitions: backend/app/Services/Booking/BookingStatusService.php

Related: AerTicket Integration

After successful payment, PaymentController creates/updates the Client from client_data, then BookingFinalizationService finalizes the booking:

  1. Creates Passenger records from traveler_data
  2. Attaches passengers to booking via booking_passenger pivot (first = lead)
  3. Attaches passengers to client via client_passenger pivot
  4. Persists flight selection to booking.flight_selection with enriched airport data (both economy and business), including per-leg breakdown with fare_price, flight_numbers, route, type, and for domestic legs: departure_time, arrival_time, airline_names, stopover_airports
  5. Creates BookingUpsell records from session selections (hotels, activities, transfers, business class flights, insurance)
  6. Persists a pending InsuranceContract when insurance was selected, then dispatches ContractInsuranceJob after commit to emit the real (irreversible) Intermundial policy out-of-band. See Travel Insurance.

Source: backend/app/Services/Booking/BookingFinalizationService.php

Idempotency: If booking already has passengers attached, finalization is skipped.

Flight storage:

  • Both economy and business flights: Stored in booking.flight_selection column with city names, duration, and legs array (per-leg fare_price, flight_numbers, route, type, cabin_class; domestic legs also include departure_time, arrival_time, airline_names, stopover_airports)
  • Business class: Additionally stored in booking_upsells.flight_search_params JSON (for financial tracking via BookingUpsell)
  • Per-leg data enables independent booking of each leg during admin-driven flight booking (see Flight Selection Storage)
CodeErrorDescription
400no_checkout_sessionNo active session, call start first
400client_data_requiredClient contact must be entered before payment
400traveler_data_requiredTravelers must be entered before payment
404offer_not_foundOffer not found or not active in market
404booking_not_foundBooking reference not found
410offer_expiredOffer is Active but within the 5-day booking lead time (departure too soon). Only returned by POST /checkout/{offerId} (start).
422validation_errorBusiness flight selection rejected: missing search metadata, stale/unknown fare_id, or missing pricing data. Returned by PUT /checkout/flights when server-side business class validation fails.

Frontend handling: All checkout step pages redirect to /{market}/home when receiving no_checkout_session error to prevent rendering with missing data.

Checkout step tracking uses an internal server-to-server API authenticated via Sanctum Bearer token.

PATCH /api/internal/bookings/{id}/checkout-step

Section titled “PATCH /api/internal/bookings/{id}/checkout-step”

Track which checkout step the user is currently viewing. Called by Astro SSR during page rendering.

Authentication: Sanctum Bearer token with internal:read ability (INTERNAL_API_TOKEN env var).

Request:

{
"step": "hotels"
}

Valid step values: flights, hotels, activities, transfers, main_contact, travelers, summary

Response: 200 OK

{
"success": true
}

Error: 422 if step value is invalid.

User navigates to /checkout/{offerId}/hotels (full page load)
-> Astro SSR: reads booking_id from Astro.session
-> Astro SSR: trackCheckoutStepSSR() calls PATCH /api/internal/...
-> BookingFunnelService::trackStep():
- Advances checkout_step forward-only (furthest step reached)
- Sets checkout_snapshot['current_step'] to actual step (even backward)
- Records timestamp if first visit to that step
-> Page renders (tracking already complete)

Every checkout step page calls trackCheckoutStepSSR(Astro.session, '<step>') in its frontmatter. Since every checkout navigation is a full page load (Astro MPA), this is 100% reliable with no client-side JS dependency.

The booking_id is bridged from React to Astro.session via POST /api/checkout/session (Astro API route) after checkout session creation.

Backward navigation: When a user navigates back (e.g., from Activities to Hotels), the frontend sends advance: false on the selection save request so advanceStep is skipped. The SSR trackStep call updates current_step to show the user’s actual position while checkout_step remains at the furthest step reached.

Source:

  • Tracking utility: frontend/src/features/checkout/server/trackCheckoutStepSSR.ts
  • Session bridge: frontend/src/pages/api/checkout/session.ts
  • Internal API client: frontend/src/shared/config/internalApiClient.ts
  • Controller: backend/app/Http/Controllers/Api/BookingController.php
ComponentFile
Controllerbackend/app/Http/Controllers/Api/CheckoutController.php
Payment Controllerbackend/app/Http/Controllers/Api/PaymentController.php
Session Servicebackend/app/Services/Checkout/CheckoutSessionService.php
Funnel Tracking Servicebackend/app/Services/Checkout/BookingFunnelService.php
Flight Options Servicebackend/app/Services/Checkout/CheckoutFlightService.php
Hotel Options Servicebackend/app/Services/Checkout/CheckoutHotelService.php
Economy Search Servicebackend/app/Services/Checkout/EconomyFlightSearchService.php
Business Search Servicebackend/app/Services/Checkout/BusinessFlightSearchService.php
Economy Cache Update Servicebackend/app/Services/Checkout/EconomyFlightCacheUpdateService.php
Checkout Flight Booking Servicebackend/app/Services/Checkout/CheckoutFlightBookingService.php
Checkout Flight Booking Jobbackend/app/Jobs/CreateCheckoutFlightBookingJob.php
Finalization Servicebackend/app/Services/Booking/BookingFinalizationService.php
Insurance Controllerbackend/app/Http/Controllers/Api/InsuranceController.php
Form Requestsbackend/app/Http/Requests/Api/Checkout/