Checkout API
Multi-step checkout flow for booking tour packages. Handles session management, selection updates, payment processing, and booking finalization.
Overview
Section titled “Overview”The checkout API provides a stateful multi-step flow:
- Start session - Initialize checkout with offer data
- Select flights - Choose outbound/inbound flights (economy or business)
- Select hotels - Optional hotel upgrades
- Select activities - Optional activity add-ons
- Select transfers - Optional transfer upgrades, plus optional travel insurance
- Enter contact - Booking contact person (client data)
- Enter travelers - Passenger personal data
- Payment - Process deposit via Stripe
- Confirmation - View booking summary
Every occupancy is self-served (#2292): any party size 1-8 and any room configuration walks the same nine steps through payment. Checkout used to divert non-standard occupancy (session['is_non_standard_pax'] === true) to a manual quotation right after Main Contact, so only an exact 2-pax-in-a-double booking could be bought online. That gate was inherited from a time when the price for those bookings was not trustworthy; it now is (CheckoutSessionService::start() recomputes land from the actual room types and re-quotes the base from the actually selected fare at the offer margin, and the below-cost gate refuses anything underwater). The only thing the diversion still bought was DMC availability confirmation for triples and singles before charging, and that moved to post-sale operations like every other booking. The quotation statuses and their admin tooling remain for agent-handled bookings — 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_checkout → purchase. Resolved via ProductByMarket::getPrimaryCountryName() and getPrimaryRegionName(); country_code comes from getDestinationCountryIsoCode(), since the same field feeds the insurance quote and must name the real destination. See GA4 Ecommerce Tracking.
Session Flow
Section titled “Session Flow”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 │ (always advances to Travelers, whatever the occupancy) │ ├─► PUT /checkout/travelers │ ├─► POST /checkout/payment/intent ├─► POST /checkout/payment/confirm │ └─► GET /checkout/confirmation/{reference}A second, agent-driven entry point exists: POST /checkout/{offerId}/resume rehydrates a quotation_confirmed booking’s stored session so the customer rejoins the funnel at Travelers. See Quotation Flow.
Endpoints
Section titled “Endpoints”POST /checkout/{offerId}
Section titled “POST /checkout/{offerId}”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):
{ "travelers": 4, "room_configuration": [ { "type": "2A", "count": 2 } ]}Request validation: See StartCheckoutRequest.
travelers(sometimes|integer|min:1|max:8): party size, defaults to 2 when omitted (NOT the offer’spax_count). Counts 5-8 are only supported on package tours, priced as a single per-person occupancy tier ({N}A) with no customer-facing room distributionroom_configuration(sometimes|array|min:1): the room split, each entry{ type, count }wheretypeis an adult-only room code in1A–8Aandcountis1–4. The total pax summed across rooms must not exceed 8
actual_pax_count and actual_room_type are not request inputs — the controller derives them from travelers / room_configuration and exposes them on the session and response (child-inclusive labels like "2A+1CH" can appear on the derived actual_room_type).
If the requested room type / pax tier cannot be priced, returns 422 Unprocessable Entity with error room_type_unavailable. Two paths converge on this same response:
- Non-package tours: a per-hotel pre-check verifies the room type exists in
supplier_service_rate_pricesfor the tour’s base hotels before the session starts. - Package tours (tours with linked package services via
SupplierTour::hasPackageServices()): the per-hotel pre-check is skipped — the package’s flat base pricing already covers hotel inclusions, so per-selection-hotel room-type rows are not required. Instead, the land-price recalculation inCheckoutSessionService::start()throwsRoomTypeUnavailableException(mapped to the same422 room_type_unavailable) when the package rate has no price for the requested{N}Atier. This keeps an unpriceable party size from surfacing as an uncaught 500.
Below-cost gate (409): before any pricing, CheckoutSessionService::start() re-prices the offer’s land component and compares flight_base_price + recomputed land + insurance_base_price against the offer’s stored final_price. An offer that was activated correctly can still go stale — a supplier price rises, a rate is replaced — and nothing re-runs the activation guard on live offers, so this is the last check before a customer commits to the stored price. On a shortfall the service throws OfferBelowCostException and the endpoint returns 409 Conflict with error: "offer_unavailable" and the customer-safe message validation.checkout.offer_price_outdated (en/es/ca/de) — the same clean “temporarily unavailable” shape as a sold-out offer. The shortfall arithmetic is logged for the operator and never shown to the customer; the fix is Recalculate selected on the offer.
The same gate also refuses a sale whose rates are not agreed: one a signed addendum withdrew from a date on or before the departure, or one an addendum still awaiting signature is renegotiating. That is a fact about the supplier agreement rather than an arithmetic result, so it blocks even though this gate is otherwise deliberately narrow. The customer sees the identical 409 shape; the logged reason names the addendum, and the fix is the contract — sign the addendum, or authorise selling it unsigned — not a recalculation.
The gate is deliberately narrower than the activation guard: only a computed shortfall blocks. When the land cannot be recomputed at all (a missing FX snapshot, a rate edit in flight) the sale proceeds on the stored price — taking offers off sale whenever an exchange-rate snapshot is late would be an outage, not a safeguard.
Pricing for non-standard pax (not 2A):
When actual_pax_count differs from the offer’s 2A default:
- Land price is recalculated using
AutoOfferGeneratorService::calculateLandPrice()for the actual room type. If no rate covers that room type (e.g. a package tour with no1Atier), the request returns422 room_type_unavailableinstead of erroring - Flight price scales linearly (offer’s per-person flight price × actual_pax_count)
- Per-pax marketing rounding applied:
rawPerPax → roundToMarketingPrice → multiply by paxCount - Session stores
is_non_standard_pax: trueto control price refresh behavior. This is a server-side session flag only —CheckoutSessionResourcehas never exposed it, and therequires_quotationboolean it used to drive was removed in #2292
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", "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 /checkout
Section titled “GET /checkout”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.
GET /checkout/{offerId}/flights
Section titled “GET /checkout/{offerId}/flights”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" }}| Field | Type | Description |
|---|---|---|
bound_flight_signature |
string | Root-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[].signature |
string | Per-leg OPERATOR+FLIGHTNUMBER (joined by + for connections), built from the live FlightSegment DTOs via OfferFlightSignature::fromFlightSegments. |
inbound_options[].signature |
string | Same 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:
- Extracts route info (departure, destination, dates) from the offer’s cached flight segments
- Applies admin-configured search filters from Settings page (airlines, max stops, fare sources, baggage, latest arrival time) with forced ECONOMY cabin class
- Detects multi-city vs round-trip based on whether inbound departure differs from outbound destination
- Searches domestic flight legs (one-way per leg) alongside international – domestic failures are graceful with cached prices as fallback
- 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/searchresults first and allows at most one/search-upselllookup 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 toduration(preserves API order) with apriceoption that re-sorts by ascendingtotalExtraPrice - Updates
DynamicFlightCachewith fresh results (top 5 fares) for both international and domestic legs - Re-links the offer’s
OfferFlightrecords and updates individual leg prices - Recalculates the offer’s
final_price(international + domestic total) - Stores economy search metadata in session for building the per-leg breakdown during flight selection:
international_fare_price(from the selected fare’stotalPrice), domestic legs enriched withsearched_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). The session itself is still repriced live: the all-legs live flight total (selected fare totalPrice + domestic searched_price, falling back to the cached 2-pax total scaled to the session’s pax count) is fed to CheckoutSessionService::repriceNonStandardPaxFromLiveSearch(), which rebuilds flight_base_price and base_price on the actual-pax axis (same margin, margin basis and marketing rounding as start()) using the session’s persisted land_price/insurance_price components. The price the customer commits to is therefore always the live one — flight drift between offer pricing and checkout is charged to the client, never absorbed into margin. The reprice is non-fatal: on failure the session keeps its stored price. Exception: sessions restored from a quotation snapshot carry quoted_price_locked = true and are never silently repriced — the customer pays exactly what the agent confirmed.
Response (success):
{ "success": true, "data": { "has_flights": true, "price_changed": true, "original_price": 2499.00, "new_price": 2549.00 }}Note: For non-standard pax, original_price/new_price report the session’s base_price before/after the live reprice (the offer’s final_price is never touched). Quotation-restored sessions (quoted_price_locked) always report price_changed: false.
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.
POST /checkout/{offerId}/business-flights
Section titled “POST /checkout/{offerId}/business-flights”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:
- Extracts route from offer’s cached flight segments (including inbound departure/destination for multi-city detection)
- Applies admin-configured search filters from Settings page via
SettingsService::buildCheckoutSearchOptions()(airlines, max stops, fare sources, baggage), with the cabin list taken from the product’s business search cabin mode (products_by_market.business_search_cabin_mode): BUSINESS and ECONOMY_PREMIUM by default, or BUSINESS only on hand-tuned quote products. The same mode is used for the post-payment booking re-search.cabinClassListis a whitelist applied to every segment, so requesting business alone returns zero fares as soon as one segment has no business cabin — which is what happens whenever an in-destination airport is the origin of the international return (aPEM→MADleg flownPEM→LIM→MAD, where the feeder is domestic equipment). Premium is the cabin that unlocks it: the feeder sells premium, the long-hauls sell business, and the fare comes back with both. See Business on mixed-cabin routes - Detects multi-city vs round-trip: multi-city when inbound departure differs from outbound destination
- Performs live Aerticket search using session’s
actual_pax_count, then keeps only the fares that fly at least one whole requested leg in business. Whole leg, not segment: a business hop feeding a lesser long-haul would charge an upgrade for the cheap half of the trip. Fares with no fully-business leg are discarded, and each surviving leg option reports the cabin it is actually flown in viacabinClass— a leg mixing cabins reports its floor, so a return flown premium on the feeder and business on the long-haul reportsECONOMY_PREMIUM - Runs a live one-way BUSINESS search for each domestic leg via
DomesticBusinessLegResolver. A leg is upgraded only when the fare is GDS content, comes back in business cabin, and is for the same physical flight the offer already sells; otherwise it keeps its cached economy price. A trip can therefore mix cabins, and each leg reports its owncabin_class - The domestic total — at whatever cabin each leg resolved to — is added to every business fare card’s “+x€” extra price calculation. An upgraded domestic leg therefore raises the supplement by its own difference, because the economy baseline it is subtracted from (
flight_base_price) already includes that leg at its economy price. 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 byFlightRankingPolicy(same hard filters, then duration → price → stops → arrival; no Pareto prune) and capped to the top 10. Per-card durations are computed viaLegDuration::forLegusing IANA airport timezones — the previous segment-sum fallback was incorrect for long-haul wallclock segments and ignored layovers - Business flights are fetched on demand when the user switches to the BUSINESS tab. The previous background prefetch (
useEffectinCheckoutPage.tsxthat fired on every checkout open) has been removed so the live Aerticket call only happens when the customer actually opts into the upgrade - Skips incomplete fares that do not contain both outbound and inbound legs
- Returns
fareIdon each outbound/inbound leg option so frontend can pair legs by fare identity (not by array index) - 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. Thetotal_extra_priceper fare is used during flight selection to computebusiness_extra_price_per_personserver-side (see PUT /checkout/flights). - For non-standard pax, passes session context (
actual_pax_count,actual_room_type,base_price) to service - Calculates prices:
- Standard 2-pax: Uses offer’s
land_base_priceandfinal_pricedirectly - 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", "domestic_legs": [ { "route": "HND-CCU", "cabin_class": "BUSINESS", "fare_price": 1775.75, "departure_date": "2026-12-03", "departure_time": "08:30", "arrival_time": "22:30", "flight_numbers": ["631", "516"], "stopover_airports": ["SIN"], "airline_names": ["Singapore Airlines"] } ], "has_flights": true, "source_type": "live_search", "original_final_price": 2499.00, "pax_count": 3, "apihubflowid": "..." }}The top-level cabinClass names the tab the customer is shopping, not what every flight is
flown in. Each outbound/inbound option carries its own cabinClass, and each entry in
domestic_legs its own cabin_class (fare_price is per traveller), so the checkout selector
can tell the customer which flights the upgrade actually covers before they pay. Labelling a leg
from the top-level value would promise business for a flight taken in economy. The
domestic_legs array is empty for trips with no in-destination flights.
Source: backend/app/Services/Checkout/BusinessFlightSearchService.php,
backend/app/Services/Checkout/DomesticBusinessLegResolver.php
PUT /checkout/flights
Section titled “PUT /checkout/flights”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, "cabin_class": "ECONOMY" }, "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).
Domestic legs follow the selected international flight. On products with domestic legs, CheckoutSessionService hands the search metadata’s domestic legs to DomesticLegSelectionReconciler together with the landing day of the fare the customer picked — read from the server-side fare_details[fare_id].outbound_arrival_date persisted at search time (never from a client value; the echoed outbound.segments are only a fallback for older sessions). A leg whose date is not landing day + day_offset is re-derived from the flight cache for that day (same selection the generator uses; per-person price = cache total ÷ offer reference pax). On a business selection the re-derived leg is quoted again by DomesticBusinessLegResolver (live business search, sellable GDS content only): it stays BUSINESS when such a fare exists for the new flight, otherwise it is stated as ECONOMY with cabin_downgraded: true on the persisted leg, and business_extra_price_per_person / business_extra_supplier_cost are recomputed with the same formula as the business search so the customer never pays a business surcharge for a leg that changed. When no compliant fare is cached on the new date the request fails with 422 on fare_id and the missing (route, date) is queued as a pending cache search, so a flight_selection is never persisted with domestic dates that do not fit its own international leg. See Domestic Leg Date Derivation.
The per-leg outbound.cabin_class / inbound.cabin_class are optional and persist into the
session so the summary and the navbar can label each flight with the cabin it is actually flown
in — a business selection can be business on one leg and premium or economy on the other.
Selections that omit them (economy, and anything saved before mixed cabins were possible) fall
back to the trip-level cabin_class. The controller stores $request->validated(), so a per-leg
key absent from the rules would be dropped silently.
These two accept ECONOMY, ECONOMY_PREMIUM or BUSINESS, one value wider than the trip-level
cabin_class, which only ever names the tab being shopped. Premium belongs here because a leg is
named by its lowest segment: a return flown premium on a domestic feeder and business on the
long-haul is labelled premium. Reject it and the customer cannot save the mixed-cabin fare at all.
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():
- Rejects if
business_search_metadatais missing from the session (no prior business search) - Rejects if
fare_idis empty - Rejects if
fare_idis not found in the storedfare_detailsmetadata (stale or unknown fare) - Rejects if
total_extra_priceis missing from the fare details - Overrides
business_extra_price_per_personwith: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)
Server-side price & identity validation (economy class):
Economy pricing is likewise driven by the selected fare_id, mirroring the business anti-fraud model. Each economy search stores per-fare details in economy_search_metadata.fare_details (fareId => { fare_price, total_extra_price, outbound_flight_numbers, inbound_flight_numbers }), built from the exact itinerary rendered for that fare.
When cabin_class=ECONOMY, buildEconomyLegs():
- Looks up the submitted
fare_idinfare_details. - Prices the international leg from that fare’s
fare_priceand stores a signedeconomy_extra_price_per_person = total_extra_price / paxCount(negative for a cheaper alternative, positive for a pricier one). Without this, the delta was display-only and lost on the next step, so the customer was charged the base price rather than the one shown. - Canonicalizes the
outbound/inboundflight numbers from the matched fare, so price and booked-flight identity always come from the same server-verified fare — a client cannot pair a cheapfare_idwith a pricier flight.
Difference vs business: economy does not require an explicit fare pick. If fare_id is absent or unknown (or the search returned no fares), it falls back to the recommended fare with a zero delta — it does not return 422.
Source: backend/app/Services/Checkout/CheckoutSessionService.php (buildEconomyLegs, calculateEconomyFlightDelta); EconomyFlightSearchService.php (searchEconomyFlightsWithMetadata / transformResults).
GET /checkout/{offerId}/hotels
Section titled “GET /checkout/{offerId}/hotels”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).
Session-aware: This endpoint runs under stateful.api (StartSession) so it can read the checkout session’s room_configuration and price upgrades for the whole group — matching what PUT /checkout/hotels charges. The frontend fetchHotelOptions sends credentials: 'include' so the session cookie reaches it.
Response shape per hotel entry:
| Field | Type | Description |
|---|---|---|
id |
string | Unique key: {hotelId}-{tier}-{startDay}-{endDay} |
hotelId |
number|null | Supplier hotel ID (null for package fallback entries) |
name |
string | Hotel name (or package name for legacy fallback) |
location |
string|null | City (null for package fallback entries) |
tier |
string | selection, luxury, or grand_luxury |
tier_label |
string | Human-readable tier label |
nights |
object | { start: number, end: number } |
imageUrl |
string|null | First hotel image URL (legacy single-image field, retained for backward compatibility) |
imageUrls |
string[] | 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) |
description |
string|null | Hotel description text from SupplierHotel |
isIncluded |
boolean | true for Selection tier, false for upgrades |
priceDifference |
number|null | Upgrade price vs Selection tier (full stay), null for Selection. Includes the fixed 20% extras margin — the converted supplier cost is margined via Offer::priceFromCost(cost, EXTRAS_MARGIN, offer basis) (÷ 0.80 on sale-basis offers, × 1.20 on legacy cost-basis) before rounding |
selectionHotelName |
string|null | Base hotel name (for upgrades), null for Selection |
imageUrls is additive – imageUrl 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 are priced across the session’s full room_configuration (e.g. [{type: "2A", count: 2}] for a 4-pax group split into two 2A rooms). Each upgrade card’s priceDifference is the whole-group supplement — the sum of upgradePrice(type) × count for every room (HotelPriceCalculatorService::calculateUpgradePriceForConfiguration()), so multi-room and 5-8 pax groups now see the correct full price instead of a single room’s. If any room type has no rate, the whole upgrade returns priceDifference: null and is shown as unavailable (non-selectable in the frontend). This mirrors what PUT /checkout/hotels charges.
The session’s room data is only trusted when it belongs to this offer (session['offer_id'] === {offerId}), mirroring the guard in transfers(). A stale session for a different offer (old tab, bookmark, browser back/forward) does not price this offer’s upgrades; it falls through to the room_type query-param fallback below. This preserves display/submit parity even across offers.
Query parameters:
| Param | Type | Description |
|---|---|---|
room_type |
string (optional) | Fallback only — used when there is no checkout session for this offer (absent, or a session belonging to a different offer). Prices a single room of that type (count 1). When a session for this offer exists, its room_configuration is the source of truth and this param is ignored. The old 1A/2A/3A/4A allowlist has been removed. |
Source: backend/app/Services/Checkout/CheckoutHotelService.php
PUT /checkout/hotels
Section titled “PUT /checkout/hotels”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, defaulttrue): Whentrue, callsadvanceStepto record funnel progression. Frontend sendsfalseon backward navigation so the funnel step is not advanced.upgrade_hotel_idrefers to a Luxury or Grand Luxury tier hotelnights_start/nights_endmust match exactly one of the stays that hotel occupies in the tour itinerary — the price is the width of that range, so it is derived from the itinerary rather than trusted from the request. A partial, widened or shifted range is rejected withhotel_upgrade_not_available(422), where previously any range merely overlapping the hotel was accepted and priced literally. A tour that sleeps in the same hotel twice offers both stays and either may be selected, but a range spanning the gap between them is not a stay and is refused.price_differenceis calculated server-side (not accepted from client), priced across the fullroom_configuration(sum ofupgradePrice(type) × countper room, viaHotelPriceCalculatorService::calculateUpgradePriceForConfiguration()) so it matches the display endpoint. It includes the fixed 20% extras margin (supplier cost margined per room viaOffer::priceFromCost(cost, EXTRAS_MARGIN, offer basis), then rounded). Legacy sessions with noroom_configurationfall back to a single room ofactual_room_type.- See Service Tiers for tier details
PUT /checkout/activities
Section titled “PUT /checkout/activities”Update activity upsell selections. Prices calculated server-side. Each activity price is per person and includes the fixed 20% extras margin (converted supplier cost margined via Offer::priceFromCost(cost, EXTRAS_MARGIN, offer basis) (÷ 0.80 on sale-basis offers, × 1.20 on legacy cost-basis), then rounded to the nearest €10).
Request:
{ "advance": true, "activity_selections": [ { "activity_id": 5, "day_number": 2, "participants": 2 } ]}advance(optional, defaulttrue): Whenfalse, skipsadvanceStep(used on backward navigation).participants(optional, integer ≥ 1): how many of the party join this activity. Absent means the full party — the behavior of sessions and frontends predating the field. The value is clamped server-side to[1, actual_pax_count]and returned on each stored selection, so session restore round-trips it. The activity’s charge isprice × participants(no per-traveler identity and no adult/child distinction — activities are priced with theper_personrate only).
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, defaulttrue): Whenfalse, skips the overlap check. The frontend sendsfalseonly on backward navigation so a partially-overlapping draft can be parked without a400.
PUT /checkout/transfers
Section titled “PUT /checkout/transfers”Update transfer selections. Prices calculated server-side.
Request:
{ "advance": true, "transfer_selections": [ { "transfer_id": 1, "day_number": 1 } ]}advance(optional, defaulttrue): Whenfalse, skipsadvanceStep(used on backward navigation).
PUT /checkout/insurance-selection
Section titled “PUT /checkout/insurance-selection”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.
Insurance is priced by a live Intermundial quote re-run server-side on every
change (the client-sent retail_price is discarded). The quoted retail_price
is the whole-party total and is added to the totals as-is — except for the
included base policy, which adds nothing and whose amount is never exposed: the
session response returns its retail_price as null.
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 /checkout/{offerId}/contact
Section titled “GET /checkout/{offerId}/contact”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
PUT /checkout/contact
Section titled “PUT /checkout/contact”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", "marketing_consent": true }}Validation: See StoreClientDataRequest for rules. No ASCII-only restriction on names (client data is not sent to airline APIs).
| Field | Type | Required | Description |
|---|---|---|---|
first_name |
string | Yes | First name (max 100 chars) |
last_name |
string | No | Last name (optional; min 2 chars when provided, max 100) |
email |
string | Yes | Valid email (max 255 chars) |
phone |
string | Yes | Phone number (max 30 chars) |
marketing_consent |
boolean | No | Optional email marketing opt-in (Confianza Online). Persisted on the client’s marketing_consent as { "email": <bool> }, merged into any existing per-channel consent by BookingFunnelService::captureContact(). Absent leaves the stored consent untouched; it never blocks the step. |
Pricing: Client data does NOT affect pricing.
Always advances to Travelers: after BookingFunnelService::captureContact(), the endpoint calls advanceStep($bookingId, CheckoutStep::Travelers, $sessionData) for every occupancy. The old diversion of is_non_standard_pax sessions into BookingFunnelService::requestQuotation() was removed in #2292, along with the requires_quotation field on the response.
Errors:
400- No checkout session (error: "no_checkout_session")400- Validation error
POST /checkout/{offerId}/resume
Section titled “POST /checkout/{offerId}/resume”Rehydrate a confirmed quotation’s checkout session from its stored snapshot, so a customer sent a continuation link can finish checkout days later with the originally quoted price intact (restore() sets quoted_price_locked, so no live search reprices it). Throttled 10/min, under the stateful.api group. See Quotation Flow for who sends that link.
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:
| Code | Error | Description |
|---|---|---|
| 400 | missing_token |
No token in request body |
| 404 | invalid_token |
No booking matches the token + offer |
| 409 | not_resumable |
Booking is not in quotation_confirmed (e.g. still pending, or already paid) |
| 410 | snapshot_missing |
Booking has no checkout_session_data snapshot to restore |
Source: backend/app/Http/Controllers/Api/CheckoutController.php (resumeQuotation)
PUT /checkout/travelers
Section titled “PUT /checkout/travelers”Store traveler personal data. Count must match the session’s actual_pax_count (falling back to pax_count for legacy sessions).
Age-composition validation: Each traveler’s DOB-derived category (adult / child / infant, via AgeCategory::fromDateOfBirth) must match the booking’s composition. The expected composition is summed across the full room_configuration — each room type’s composition (2A → 2 adults, 1A1CH → 1 adult + 1 child, etc.) multiplied by its count — so a multi-room booking expects the whole group rather than a single room (fixes false rejections like “expected 2 adult, but 4 provided”). Legacy sessions with no room_configuration fall back to parsing actual_room_type. A mismatch returns 400 with error: "validation_error". Source: StoreTravelerDataRequest (expectedPaxComposition / parseRoomTypeComposition).
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 /checkout/confirmation/{reference}
Section titled “GET /checkout/confirmation/{reference}”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://..." }}Price Calculation
Section titled “Price Calculation”Prices update automatically as selections change:
total_price = base_price + extras_price
extras_price = sum of: + business_extra_price_per_person * actual_pax_count (offer margin, on basis) + economy_extra_price_per_person * actual_pax_count (signed fare delta, offer margin on basis) + hotel_price_differences (priced across the session's full room_configuration; fixed 20% extras margin) + activity_prices * actual_pax_count (fixed 20% extras margin) + transfer_prices (fixed 20% extras margin, like hotels/activities) + insurance_retail_price (whole-party total; paid upgrades carry the session margin)Margin invariant (margin on the whole sale): every cost component is priced as Offer::priceFromCost(cost, margin, basis), so the total is always Σ(costᵢ ÷ (1 − m/100)) = (Σ costᵢ) ÷ (1 − m/100) — i.e. the configured margin is a real share of the entire selling price, not just the package. There are no pass-through (zero-margin) lines: the package uses the offer’s margin; hotels, activities, and transfers use the fixed Offer::EXTRAS_MARGIN; business and economy flight upgrades use the offer’s margin (a signed economy fare delta is marked up on the offer’s basis so a pricier/cheaper flight keeps the booking at margin-on-sale); paid insurance upgrades use the session margin via CheckoutInsuranceService::clientPriceFor(). The included insurance policy is the sole exception (retail as-is — it is bundled free into the package, contributing 0 to the total). Post-purchase flight drift (actual fare differs from the priced one at ticketing) is a separate, deliberate risk model — charged/absorbed by Volāre, never re-margined — so a booking’s realized P&L can still dip below the configured margin after ticketing. See Extras Pricing & Rounding and Margin Basis.
Margin basis in the session: start() freezes both the offer’s margin and its margin_basis (cost | sale) into the session, and both travel into the booking snapshot at finalization. Every reprice path (checkout resume, non-standard-pax live reprice, insurance quotes) reads the basis back with MarginBasis::fromSnapshot(); sessions or snapshots persisted before the field existed resolve to the legacy cost basis, so a checkout started before the margin-on-sale switch completes at exactly the price the customer saw.
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); economy applies the same model — the selected fare’s signed total_extra_price drives economy_extra_price_per_person, and the fare’s flight numbers are canonicalized so price and flight identity cannot diverge. 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 sums each room’s supplement across the session’s full room_configuration, so a group split into multiple rooms is charged for every room.
Activity rate date: Activity price and cost rate lookups use the date the activity actually happens (departure_date + day_number − 1), not the departure date, so an activity later in the trip that crosses a seasonal rate boundary is priced from the correct rate window.
Supplier costs captured at selection: Alongside the customer price, the session records the raw pre-margin supplier cost of each selection — supplier_cost on hotel selections (HotelPriceCalculatorService::calculateUpgradeCostForConfiguration()) and per-person supplier_unit_cost on activity selections (ActivityPriceCalculatorService::getActivityCost()). The session also persists the pre-margin land_price and insurance_price components set at start(). These purchase-time figures feed the Arkana booking cost breakdown (Bookings — Cost Breakdown) and are immutable: a later offer reprice never rewrites a historical booking’s P&L.
Quotation Flow (agent-driven)
Section titled “Quotation Flow (agent-driven)”The quotation_requested / quotation_confirmed statuses, their Arkana actions, the continuation email and the resume endpoint all still exist — but nothing in the self-service funnel produces a quotation anymore. Since #2292 BookingFunnelService::requestQuotation() has no caller in app/ (only tests exercise it), so today a booking enters quotation_requested only when an agent sets that status by hand on the Arkana Edit Booking page (checkout → quotation_requested is a normal forward transition; from any other status it is an audited manual override requiring a reason).
From that point the machinery is unchanged:
- Confirm Availability (booking view page, visible while
quotation_requested) records the DMC’s supplier confirmation as a status note and moves the booking toquotation_confirmed. - Send Checkout Link (visible while
quotation_confirmed) mailsCheckoutContinuationNotification, whose CTA isBooking::getCheckoutContinuationUrl()({frontend}/{market}/checkout/{offerId}/travelers?resume={resumption_token}&sig={per-send signature}). - The Travelers page calls
POST /checkout/{offerId}/resume, which rehydrates the stored session so the customer finishes at Travelers + Payment with the originally quoted price. Loading the page with?resume=also records aCheckoutLinkOpenedtouchpoint via the internal link-open endpoint, surfaced in the booking’s Preview Views section.
Snapshot caveat: resume reads booking.checkout_session_data, which is written only by requestQuotation() (uncalled) and by the Redsys payment-intent path — the funnel’s advanceStep() writes checkout_snapshot instead. A live checkout flipped to a quotation status by hand therefore has no snapshot, and its continuation link answers 410 snapshot_missing.
Payment gate: PaymentController::createIntent only promotes bookings in Draft | Checkout | QuotationConfirmed. A QuotationRequested booking cannot be paid until an agent confirms.
Related notifications: See Quotation Notifications.
Source: backend/app/Filament/Resources/Bookings/Pages/ViewBooking.php (quotation actions), backend/app/Services/Checkout/BookingFunnelService.php
Session Resource Fields
Section titled “Session Resource Fields”CheckoutSessionResource returns the 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). It does not expose is_non_standard_pax — that flag stays server-side — and the requires_quotation boolean derived from it was removed in #2292.
It also returns requires_deposit, which mirrors the 60-day cutoff the payment intent applies. When it is false the departure is too close to split the payment: deposit_amount equals total_price, and the summary must show only the total — no deposit line and no split-payment notice. See Deposit/Balance Payments.
Payment Flow
Section titled “Payment Flow”See Payment Gateway System for full payment documentation.
Key points:
createIntentcomputes the deposit asraw_flight_cost + margin% × total_price(capped at total) viaPaymentCalculatorService::depositFromCheckout(), using the session’stotal_price(including extras). Departures 60 days out or less are charged in full instead. See Deposit/Balance Payments- Both payment endpoints accept an optional
electronic_invoiceboolean (Confianza Online opt-in from the summary step, persisted asbookings.electronic_invoice_accepted):createIntentrecords it before branching by gateway — Redsys redirects the customer away right after, so intent is its only capture point — whileconfirmre-sends the value chosen at actual submission (the checkbox stays interactive while the Stripe form is mounted), superseding the intent-time capture. Absent keeps the paper default (false) confirmcreates/updates Client fromclient_data(nottraveler_data[0]), then callsBookingFinalizationService::finalizeFromCheckoutSession()- Booking stores
payment_method_codeandpayment_gateway_codefor operational tracking - Status is routed via
PaymentService::updateBookingPaymentStatus():- Flight booking (
ECONOMY/BUSINESS) ->pending_flight_booking - Land-only booking ->
pending_land_confirmation
- Flight booking (
- Session cleanup: After successful payment (PaymentStatus::Succeeded), the checkout session is cleared via
CheckoutSessionService::clear()to prevent session reuse - After payment, redirect to
/confirmation/{reference}
Admin-Driven Flight Booking (Per-Leg)
Section titled “Admin-Driven Flight Booking (Per-Leg)”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:
- Admin clicks Book All Flights / Book Flight (or Retry All Flights / Retry) in Filament
- Booking transitions to
flight_booking_in_progress - Action computes which legs are still unbooked (skips legs with existing
FlightBookingrecords) - Dispatches one
CreateCheckoutFlightBookingJobper unbooked leg toaerticket-bookingsqueue (each with$legIndexparameter) - Each job independently:
- International legs: Re-searches via
EconomyFlightSearchService::searchInternationalOnly()(economy) orBusinessFlightSearchService::searchBusinessFlightsRaw()(business), matches round-trip by flight numbers + price - Domestic legs: Searches one-way via
AerticketSearchService, matches by flight numbers + price
- International legs: Re-searches via
- Verifies availability with Aerticket
- Creates booking via
AerticketBookService - Creates
FlightBookingrecord linked toBookingviabooking_idFK withleg_indexandflight_type - Dispatches
AerticketRetrieveBookingJobto fetch PNR details - All-legs-booked check: Only transitions to
flights_confirmedwhen ALL legs haveFlightBookingrecords. Handles concurrent transition race with try/catch onInvalidArgumentException.
- Partial success -> admin notification “Leg N booked (M/N legs)”
- All legs booked ->
flights_confirmed - Failure ->
flight_booking_failed(with error metadata includingleg_index)
Business class specifics:
- Business fares are bundled (1 fare = both legs, itinerary index 1 only), unlike economy mix-and-match
BookingUpsellwith typeflight_upgradecontinues 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. The rule of thumb: transient failures are retried, deterministic ones fail fast so the actionable metadata reaches an admin immediately.
- noMatchingFare (selected flight not found in the live response): Released back to queue after 300 seconds (5 minutes) to allow fare inventory to refresh. If still failing on the second attempt, the job is explicitly failed.
- priceDriftBeyondTolerance (flight found, price outside the tolerance window): Failed immediately, no retry — a published price change will not reverse itself on a retry, so the booking surfaces the drift for a human decision instead.
- domesticDateMismatch (domestic leg’s stored date is not the booked international arrival +
day_offset): Failed immediately, no retry, before any supplier call. The frozenflight_selectioncannot fix itself; an operator replaces the leg withbookings:replace-domestic-legsand books it again. Context carriesstored_date,expected_date,international_arrival,day_offset,routeandflight_numbers. The guard is skipped only when the offer has no domestic flight config to validate against. See Domestic Leg Date Derivation. - segmentSellFailed (GDS rejected the segment sell): Released after 120 seconds once, with a fresh search on the retry, then failed. Parsed flight details (date, route, flight number, booking class) are logged.
- Other failures (searchFailed, verifyFailed, bookingFailed, missingExpectedPrice): Re-thrown for standard framework retry with 120 seconds backoff via
$backoff.
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). Failure notifications carry the compared prices (
Expected … · Live … · Drift …) whenever the exception context holds them, so a drift can be judged without opening the booking. - 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 Exception | Context Fields |
|---|---|
AerticketPriceChangeException |
sub_type, original_price, new_price, currency, percentage_change |
AerticketFareExpiredException |
sub_type, fare_id, expired_at |
AerticketTimeoutException |
sub_type (timeout) |
AerticketValidationException |
sub_type, validation_errors |
AerticketVerifyException |
sub_type, fare_id |
AerticketBookingException |
sub_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. priceDriftBeyondTolerance adds expected_price, live_price, price_diff and the tolerance window in force; missingExpectedPrice adds booking_id, cabin_class, route and flight_numbers.
The status timeline Blade component displays the message field from failed transition metadata directly under the status badge. On the booking view, the failed leg’s own section also renders an Error line plus, for price failures, a Price Comparison line (Expected … · Live … · Drift …) read from the latest failed transition’s metadata — matched to the leg via its leg_index.
Matching logic:
Identity and price are two separate stages: the customer’s flight is identified by its physical signature, never by price, and price drift is then enforced as a post-match guard so a drifted price fails as price_drift_beyond_tolerance instead of a misleading “no fare found”.
- Identity — International (economy and business): signature match (operating carrier + flight number per segment, from the snapshot’s segment arrays) on both outbound and inbound legs. A stored
fare_idis preferred when the live response still publishes it, defended by a signature re-check so a stale id cannot bind a different flight. Snapshots predating per-segment storage fall back to exact outbound/inbound flight-number matching. Domestic: one-way search matched by flight numbers;findMatchingOneWayFare()matches on the outbound leg only. - Fare-family disambiguation — Aerticket commonly returns several fare rows for the same physical flight. Among identity-matched candidates,
pickClosestToExpectedPrice()picks the one closest to the expected price within the repricing tolerance window; if none fit, the absolute closest is returned so the guard reports useful diagnostics. - Price guard —
guardPriceDrift()throwspriceDriftBeyondTolerancewhenlive_price − expected_pricefalls outside the window. Skipped entirely when the expected price resolves to0(legacy snapshots).
Expected price is always the booked leg’s own cost, never the whole package:
bookLegForCheckout() resolves the leg being booked once, by leg_index, and hands it to the booking routine — so both paths price against that same leg’s own fare_price × number_of_travelers and there is no second, independent derivation of “which leg is this”.
- International: the booked leg’s own
fare_price × number_of_travelers. This matters because the live re-search prices only the international round-trip — comparing it againstfare_total_price(which also covers the domestic legs) would report a phantom drift equal to the domestic legs’ cost. A resolved leg carrying no usable price throwsmissingExpectedPricebefore the supplier search rather than booking unvalidated. - Domestic: likewise that leg’s own
fare_price × number_of_travelers. - Legacy selections without a
legsbreakdown fall back toflight_selection.fare_total_price(international-only at the time it was written), then to the offer’sflight_base_pricefor the economy 2-pax shape, then to0.
fare_price is always stored per-person in flight_selection.legs. The tolerance window is the admin Flights → Re-Pricing setting (aerticket.reprice.tolerance_min / _max, default ±5 EUR), shared with ticketing-time repricing. All figures on both sides are raw supplier cost — margin never enters this comparison.
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
Booking Finalization
Section titled “Booking Finalization”After successful payment, PaymentController creates/updates the Client from client_data, then BookingFinalizationService finalizes the booking:
- Creates Passenger records from
traveler_data - Attaches passengers to booking via
booking_passengerpivot (first = lead) - Attaches passengers to client via
client_passengerpivot - Persists flight selection to
booking.flight_selectionwith enriched airport data (both economy and business), including per-leg breakdown withfare_price,flight_numbers,route,type, and for domestic legs:departure_time,arrival_time,airline_names,stopover_airports - Creates BookingUpsell records from session selections (hotels, activities, transfers, business class flights, insurance). Each row also persists its
cost_price— the raw supplier cost captured at selection (hotelsupplier_cost, activitysupplier_unit_cost × travelers, transfersupplier_cost, insurance quoted whole-party retail) — so the booking’s cost breakdown reads real purchase-time costs instead of backing the margin out of retail - Persists a pending InsuranceContract when insurance was selected, then dispatches
ContractInsuranceJobafter 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_selectioncolumn with city names, duration, andlegsarray (per-legfare_price,flight_numbers,route,type,cabin_class; domestic legs also includedeparture_time,arrival_time,airline_names,stopover_airports) - Business class: Additionally stored in
booking_upsells.flight_search_paramsJSON (for financial tracking via BookingUpsell) - Per-leg data enables independent booking of each leg during admin-driven flight booking (see Flight Selection Storage)
Error Responses
Section titled “Error Responses”| Code | Error | Description |
|---|---|---|
| 400 | no_checkout_session |
No active session, call start first |
| 400 | client_data_required |
Client contact must be entered before payment |
| 400 | traveler_data_required |
Travelers must be entered before payment |
| 404 | offer_not_found |
Offer not found or not active in market |
| 404 | booking_not_found |
Booking reference not found |
| 409 | offer_unavailable |
The offer’s recomputed cost exceeds its stored selling price, or a rate behind it is not on sale (OfferBelowCostException). Message key validation.checkout.offer_price_outdated. Only returned by POST /checkout/{offerId} (start) — see Below-cost gate. |
| 410 | offer_expired |
Offer is Active but within the 5-day booking lead time (departure too soon). Only returned by POST /checkout/{offerId} (start). |
| 422 | room_type_unavailable |
The requested room type / pax tier cannot be priced — no covering rate for that tier. Returned by POST /checkout/{offerId} (start), from either the per-hotel pre-check or RoomTypeUnavailableException. |
| 422 | validation_error |
Business 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.
Funnel Step Tracking (Internal API)
Section titled “Funnel Step Tracking (Internal API)”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.
POST /api/internal/checkout/link-open
Section titled “POST /api/internal/checkout/link-open”Record a CheckoutLinkOpened touchpoint when a checkout page is loaded with a ?resume= token (the continuation link emailed by the “Send Checkout Link” admin action, or the same URL copied/shared elsewhere). Called by Astro SSR (trackCheckoutLinkOpenSSR()), which forwards the real visitor signals as X-Client-* headers, mirroring trip-preview view tracking. The opens are surfaced in the booking admin page’s Preview Views section.
Authentication: Sanctum Bearer token with internal:read ability (INTERNAL_API_TOKEN env var).
Request:
{ "token": "c52f8a7b-3b5d-4f0b-a94f-38586f51dca6", "offer_id": 20970, "link_signature": "UvzP147ZhvrE", "market": "es", "locale": "es"}The booking is located by resumption_token + offer_id. link_signature is the per-send sig minted by CheckoutContinuationNotification: when present the open is attributed to that email (origin: email); when absent it is recorded as origin: shared (copied/forwarded link).
Response: 200 OK
{ "success": true}Errors: 404 (booking_not_found) for an unknown token/offer pair, 422 on validation failure.
Architecture
Section titled “Architecture”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
Source Files
Section titled “Source Files”| Component | File |
|---|---|
| Controller | backend/app/Http/Controllers/Api/CheckoutController.php |
| Payment Controller | backend/app/Http/Controllers/Api/PaymentController.php |
| Session Service | backend/app/Services/Checkout/CheckoutSessionService.php |
| Funnel Tracking Service | backend/app/Services/Checkout/BookingFunnelService.php |
| Flight Options Service | backend/app/Services/Checkout/CheckoutFlightService.php |
| Hotel Options Service | backend/app/Services/Checkout/CheckoutHotelService.php |
| Economy Search Service | backend/app/Services/Checkout/EconomyFlightSearchService.php |
| Business Search Service | backend/app/Services/Checkout/BusinessFlightSearchService.php |
| Economy Cache Update Service | backend/app/Services/Checkout/EconomyFlightCacheUpdateService.php |
| Checkout Flight Booking Service | backend/app/Services/Checkout/CheckoutFlightBookingService.php |
| Checkout Flight Booking Job | backend/app/Jobs/CreateCheckoutFlightBookingJob.php |
| Finalization Service | backend/app/Services/Booking/BookingFinalizationService.php |
| Insurance Controller | backend/app/Http/Controllers/Api/InsuranceController.php |
| Form Requests | backend/app/Http/Requests/Api/Checkout/ |
Related
Section titled “Related”- Travel Insurance (Intermundial) - Insurance policies, live quote, post-payment emission
- Payment Gateway System - Payment processing
- Bookings - Booking data model and checkout funnel tracking
- Swagger: http://localhost/api/documentation