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
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_checkout → purchase. Resolved via ProductByMarket::getPrimaryCountryName(), getPrimaryRegionName(), and getPrimaryCountryIsoCode(). 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 │ │ │ ├─ 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}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):
{ "actual_pax_count": 3, "actual_room_type": "2A+1CH"}Request validation:
actual_pax_count(optional): 1-4 passengers, defaults to offer’s pax_countactual_room_type(optional): Must exist insupplier_service_rate_pricesfor 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:
- Land price is recalculated using
AutoOfferGeneratorService::calculateLandPrice()for the actual room type - 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
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 /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), 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.
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 forced BUSINESS cabin class - 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 - 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)
- 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 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", "has_flights": true, "source_type": "live_search", "original_final_price": 2499.00, "pax_count": 3, "apihubflowid": "..." }}Source: backend/app/Services/Checkout/BusinessFlightSearchService.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 }, "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():
- 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)
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).
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 | Price difference vs Selection tier, null for Selection |
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 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:
| Param | Type | Description |
|---|---|---|
room_type | string (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
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 hotelprice_differenceis calculated server-side (not accepted from client)- See Service Tiers for tier details
PUT /checkout/activities
Section titled “PUT /checkout/activities”Update activity upsell selections. Prices calculated server-side.
Request:
{ "advance": true, "activity_selections": [ { "activity_id": 5, "day_number": 2 } ]}advance(optional, defaulttrue): Whenfalse, skipsadvanceStep(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, 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.
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 /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" }}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) |
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
POST /checkout/{offerId}/resume
Section titled “POST /checkout/{offerId}/resume”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:
| 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 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 /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 + 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.
Quotation Flow (non-standard pax)
Section titled “Quotation Flow (non-standard pax)”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:
- Customer completes checkout up to Main Contact (
PUT /checkout/contact). BookingFunnelService::requestQuotation()snapshots the session tobooking.checkout_session_data, extendsexpires_at(quotation_days, 30), and transitionsCheckout → QuotationRequested. The funnel does NOT advance to Travelers.QuotationRequestedNotificationalerts the reservations inbox (mail) and sales agents/admins (Arkana bell). A Volare agent confirms availability with the DMC.- Agent moves the booking to
QuotationConfirmedand sendsCheckoutContinuationNotificationto the customer, whose CTA links toBooking::getCheckoutContinuationUrl()({frontend}/{market}/checkout/{offerId}/travelers?resume={resumption_token}). - 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.
Payment Flow
Section titled “Payment Flow”See Payment Gateway System for full payment documentation.
Key points:
createIntentuses session’stotal_price(including extras) for deposit calculationconfirmcreates/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:
- 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
$backoffproperty.
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 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.
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_countmust match fare’stotalPrice(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_priceis always stored per-person inflight_selection.legs. - Domestic: One-way search, matched by flight numbers + price (
fare_price * pax_countfor 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
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)
- 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 |
| 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 | 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.
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