Skip to content

Bookings

Bookings track the full lifecycle from checkout start through payment, admin operations, and trip completion. A Checkout booking is created when checkout begins, enabling funnel drop-off analysis before any payment occurs.

Booking
├── Offer (the package being booked)
├── Client (who is booking)
├── Currency (payment currency)
├── FlightBookings (one-to-many, one per leg)
│ └── booking_id, leg_index, flight_type
├── Passengers (many-to-many via booking_passenger)
│ └── is_lead_passenger (pivot field)
└── Upsells (optional extras)
└── Hotels, Activities, Transfers, Flight Upgrades

Table: bookings

Column Type Description
id bigint Primary key
offer_id bigint FK to offers (cascade delete)
client_id bigint FK to clients (nullable, cascade delete)
booking_reference varchar(16) Unique reference (BK-XXXXXXXX)
status varchar draft / checkout / quotation_requested / quotation_confirmed / pending_payment / payment_processing / pending_flight_booking / flight_booking_in_progress / flight_booking_failed / flights_confirmed / pending_land_confirmation / confirmed / awaiting_balance / fully_paid / completed / cancelled / expired
checkout_step varchar Current funnel step (see CheckoutStep enum)
checkout_started_at timestamp When checkout began (nullable)
checkout_completed_at timestamp When checkout completed payment (nullable)
checkout_session_id varchar PHP session ID at checkout start (nullable)
checkout_snapshot json Step timestamps and session data snapshot (nullable)
contact_email varchar Contact email captured at main_contact step (nullable)
contact_phone varchar Contact phone captured at main_contact step (nullable)
contact_name varchar Contact name captured at main_contact step (nullable)
electronic_invoice_accepted boolean Consent to receive the invoice electronically, captured on the summary/payment step (Confianza Online). Default: false (paper)
expires_at timestamp Draft booking expiration (nullable)
number_of_travelers smallint Passenger count (default: 2)
total_amount decimal(10,2) Booking total (base + extras)
base_price decimal(10,2) Offer price at booking time (nullable)
extras_price decimal(10,2) Sum of upsell prices (default: 0)
margin decimal(5,2) Offer margin frozen onto the booking at checkout (nullable). Freezes historical P&L so editing/migrating the now-editable offer never mutates a paid booking’s COGS
margin_basis varchar Offer margin basis frozen at checkout (cost / sale), paired with margin
currency_id bigint FK to currencies (restrict delete)
payment_method_code varchar(30) Payment method used at checkout (nullable)
payment_gateway_code varchar(30) Gateway that processed payment (nullable)
deposit_amount decimal(12,2) Initial deposit amount (nullable)
balance_amount decimal(12,2) Remaining balance (nullable)
balance_due_at timestamp Balance payment deadline (nullable)
checkout_session_data json Full checkout session snapshot, persisted when a non-standard-pax booking becomes a quotation, so the continuation link can rehydrate the session days later (nullable)
flight_selection jsonb Flight selection with enriched data - economy or business (nullable)
notes text Optional notes
booked_at timestamp When booking was made
trip_shared_at timestamp Set the first time trip access emails are sent to all passengers (auto-set on first payment, or by the Filament manual “Share trip with pax” action). Used as an idempotency guard so customers never receive duplicate emails (nullable)
main_contact_notified_at timestamp Stamped the first time a lead submits contact details on the Main Contact step, so the internal “main contact captured” alert (BookingMainContactCapturedNotification) is not re-sent when the lead edits and resubmits (nullable)

Indexes:

  • (status, booked_at) - For status-filtered date queries
  • (client_id, status) - For client booking history
  • (balance_due_at) - For upcoming balance deadline queries

Table: booking_passenger

Column Type Description
booking_id bigint FK to bookings (cascade delete)
passenger_id bigint FK to passengers (cascade delete)
is_lead_passenger boolean Lead passenger flag (default: false)

Constraint: Unique (booking_id, passenger_id)

Table: booking_upsells

Stores selected extras for a booking using a selective FK pattern - only one supplier FK is populated per row (except for flight upgrades which use JSON storage).

Column Type Description
id bigint Primary key
booking_id bigint FK to bookings (cascade delete)
type varchar(20) ‘hotel’, ‘activity’, ‘transfer’, ‘flight_upgrade’, ‘insurance’
supplier_hotel_id bigint FK to supplier_hotels (null on delete)
supplier_activity_id bigint FK to supplier_activities (null on delete)
supplier_transfer_id bigint FK to supplier_transfers (null on delete)
supplier_insurance_id bigint FK to supplier_insurances (null on delete)
unit_price decimal(10,2) Price per unit in EUR at booking time (what the customer pays)
quantity smallint Number of units (default: 1)
total_price decimal(10,2) unit_price × quantity
cost_price decimal(10,2) Raw supplier cost captured at selection — what Volāre pays, as opposed to the customer price (nullable)
day smallint Itinerary day number (nullable)
flight_search_params json Flight details for upgrades (nullable)

Selective FK Pattern: The type column determines which FK is populated:

  • type='hotel'supplier_hotel_id is set, others null
  • type='activity'supplier_activity_id is set, others null
  • type='transfer'supplier_transfer_id is set, others null
  • type='insurance'supplier_insurance_id is set, others null
  • type='flight_upgrade' → No FK, uses flight_search_params JSON instead

Index: (booking_id, type) - For filtering upsells by type

Table: booking_status_transitions

Immutable audit trail for every booking status transition.

Column Type Description
id bigint Primary key
booking_id bigint FK to bookings (cascade delete)
from_status varchar(30) Previous status (nullable for initial creation)
to_status varchar(30) New status
user_id bigint FK to users (nullable, null for system transitions)
notes text Optional operator notes
metadata json Structured context (error details, references)
transitioned_at timestamp Transition timestamp

Indexes:

  • (booking_id, transitioned_at) - Timeline queries
  • (to_status) - Status reporting queries

Booking and payment statuses are unified in a single BookingStatus enum.

Checkout Flow:
Draft/Checkout -> PendingPayment -> PaymentProcessing
| | |
+-> Cancelled +-> Expired +-> Cancelled
+-> PendingPayment (retry)
Quotation Flow (non-standard pax: actual_pax_count != 2 or non-default room):
Checkout -> QuotationRequested -> QuotationConfirmed -> PendingPayment
| |
+-> Cancelled/Expired +-> Cancelled/Expired
Post-Payment Routing:
PaymentProcessing -> PendingFlightBooking -> FlightBookingInProgress -> FlightsConfirmed
| | |
+-> Cancelled +-> FlightBookingFailed+-> PendingLandConfirmation
| |
+-> FlightBookingInProgress
|
+-> AwaitingBalance / FullyPaid
Land-Only Routing:
PaymentProcessing -> PendingLandConfirmation -> AwaitingBalance / FullyPaid
Payment Completion:
AwaitingBalance -> FullyPaid -> Completed
| | |
+-> Cancelled +-> Cancelled+-> (final)
Status Value Description
Draft draft Legacy pre-checkout status kept for compatibility in transition rules
Checkout checkout Active checkout session in progress
QuotationRequested quotation_requested Non-standard-pax booking awaiting manual DMC availability confirmation by a Volāre agent (color warning)
QuotationConfirmed quotation_confirmed Agent confirmed availability; customer invited back to finish checkout and pay (color info)
PendingPayment pending_payment Checkout finalized, waiting for payment confirmation
PaymentProcessing payment_processing Payment confirmation in progress
PendingFlightBooking pending_flight_booking Payment succeeded and booking includes flights; waiting for admin to trigger flight booking
FlightBookingInProgress flight_booking_in_progress Flight booking job running
FlightBookingFailed flight_booking_failed Last flight booking attempt failed; retry available
FlightsConfirmed flights_confirmed Flights booked successfully; waiting for land service confirmation
PendingLandConfirmation pending_land_confirmation Waiting for admin confirmation of land services
Confirmed confirmed All booked services confirmed (legacy, not actively used in current flow)
AwaitingBalance awaiting_balance Deposit paid, balance outstanding
FullyPaid fully_paid Full payment received
Completed completed Trip completed (final state)
Cancelled cancelled Booking cancelled (final state)
Expired expired Draft booking expired without payment (final state)

Transition rules: See BookingStatus::canTransitionTo() for allowed transitions.

Payment invariant: AwaitingBalance → FullyPaid only happens when succeeded payments actually cover total_amount (Booking::outstandingBalance() ≤ 0.01). A partial success — e.g. a stale superseded attempt succeeding late with a smaller amount — leaves the booking awaiting the remainder and logs a warning instead of closing it (PaymentService::updateBookingPaymentStatus()).

Statuses can also be changed outside the forward-only state machine (rollbacks, corrections of mis-clicked final states) via BookingStatusService::override():

  • Always audited: the booking_status_transitions row records the acting user, the moment, a mandatory non-blank reason (validated in the domain service, not just the form), and metadata.manual_override = true.
  • Hard invariants still apply: a status that requiresPassengers() cannot be forced onto a booking without them.
  • Paid statuses stay overridable — offline bank transfers are recorded by moving a booking to FullyPaid manually — but when payments do not cover the total, the shortfall is stamped into the audit row as metadata.unpaid_at_override and logged, so it can never pass silently.

In Filament, the Edit page routes in-flow changes through transition() and out-of-flow changes through override(), requiring the reason field for the latter.

Source: backend/app/Services/Booking/BookingStatusService.php, backend/app/Filament/Resources/Bookings/Pages/EditBooking.php

Quotation helpers: Both quotation statuses are reported by requiresAdminAction(), isInCheckoutFlow(), and isPrePayment(), and by the new isQuotation() helper. They are excluded from requiresPassengers() (no passengers exist yet). See Quotation Flow.

Source: backend/app/Enums/BookingStatus.php

Checkout-flow bookings (draft / checkout) track which checkout step each user reaches, enabling drop-off analysis.

Steps are ordered by CheckoutStep::ordinal():

Step Value Ordinal Description
Flights flights 0 Initial step (set on checkout booking creation)
Hotels hotels 1 Hotel upgrade selection
Activities activities 2 Activity add-on selection
Transfers transfers 3 Transfer selection
MainContact main_contact 4 Booking contact person data
Travelers travelers 5 Passenger personal data
Summary summary 6 Review and payment
Completed completed 7 Payment successful (terminal state)

Source: backend/app/Enums/CheckoutStep.php

The checkout_step column always reflects the furthest step reached (forward-only). A separate checkout_snapshot['current_step'] field tracks the user’s actual position, including backward navigation.

Two mechanisms update checkout funnel data:

  1. trackStep() – Called via the internal API (PATCH /api/internal/bookings/{id}/checkout-step) during Astro SSR page rendering. Advances checkout_step forward-only. Always updates checkout_snapshot['current_step'] to the actual step being viewed (even on backward navigation). Records a timestamp in step_timestamps if one doesn’t already exist for that step.
  2. advanceStep() – Called on PUT selection endpoints (hotels, activities, transfers). Records a step timestamp in checkout_snapshot, syncs pricing from the session, and advances checkout_step forward-only as a fallback when SSR tracking fails. Only called on forward navigation (controlled by the advance request flag, defaults to true). Flights, contact, and travelers endpoints always call advanceStep.

Contact capture happens at the main_contact step via captureContact(), which also extends the checkout booking expiration from booking.expiration.anonymous_days (10) to booking.expiration.lead_days (30).

On successful checkout finalization (before charge confirmation), promoteToPendingPayment() transitions the booking from checkout flow status to pending_payment, sets checkout_step to Completed, and records checkout_completed_at.

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

The checkout_snapshot JSON stores step timestamps, pricing, and the user’s current position:

{
"step_timestamps": {
"flights": "2026-02-15T10:30:00+00:00",
"hotels": "2026-02-15T10:32:15+00:00",
"activities": "2026-02-15T10:35:00+00:00"
},
"current_step": "activities",
"base_price": 2499.00,
"extras_price": 150.00,
"total_price": 2649.00
}

Checkout-flow bookings expire automatically:

  • Anonymous (no contact): booking.expiration.anonymous_days (10 days)
  • With contact: booking.expiration.lead_days (30 days)
  • Quotation (non-standard pax awaiting DMC confirmation): booking.expiration.quotation_days (30 days, env BOOKING_QUOTATION_EXPIRATION_DAYS)

Config: backend/config/booking.php

Auto-generated on creation with format BK-XXXXXXXX (8 uppercase alphanumeric characters).

// Generation logic in Booking model
$reference = 'BK-' . strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));

Source: backend/app/Models/Booking.php

$booking->offer; // BelongsTo Offer
$booking->client; // BelongsTo Client
$booking->currency; // BelongsTo Currency
$booking->flightBookings; // HasMany FlightBooking (one per leg)
$booking->flightBooking; // HasOne FlightBooking (oldest, backward-compat)
$booking->passengers; // BelongsToMany Passenger (with pivot)
$booking->leadPassenger(); // Single Passenger or null
$booking->upsells; // HasMany BookingUpsell
$booking->payments; // HasMany Payment
$booking->statusTransitions; // HasMany BookingStatusTransition
$booking->latestTransition; // HasOne BookingStatusTransition
$booking->canBeCancelled(); // true if status allows cancellation
$booking->canBeConfirmed(); // true if PendingPayment
$booking->canBeCompleted(); // true if Confirmed or FullyPaid
$booking->requiresBalancePayment(); // true if AwaitingBalance
$booking->getSuccessfulPayments(); // Collection of successful Payment records
$booking->totalPaidAmount(); // Sum of succeeded payments (always queried fresh)
$booking->outstandingBalance(); // max(0, total_amount − totalPaidAmount())

outstandingBalance() is the single source of truth for what the client still owes: payment links, Redsys initiation, the admin UI (balance shown/emailed) and the fully-paid transition all derive from it so the displayed, emailed and charged amounts can never diverge.

Resource: backend/app/Filament/Resources/Bookings/BookingResource.php

Page Description
List View all bookings with filters
Create New booking with offer/client/passenger selection
View Booking summary, cost breakdown (P&L, payments, pricing, direct costs), flight cost detail, payment, extras, and checkout funnel sections
Edit Status changes only (out-of-flow changes require an audited override reason)

Permissions: ViewBooking, CreateBooking, UpdateBooking, DeleteBooking

  1. Select an active Offer
  2. Select a Client
  3. Attach passengers from client’s saved passengers
  4. Designate one lead passenger
  5. Select optional extras (hotels, activities, transfers)
  6. Review pricing (base + extras = total)

Only the status field can be modified after creation. In-flow changes follow the allowed transitions; any other change is a manual override that requires a mandatory reason and is flagged in the audit trail.

View Page - Booking Summary & Cost Breakdown

Section titled “View Page - Booking Summary & Cost Breakdown”

The view page opens with an at-a-glance Booking Summary (reference, total, pax, product/offer links, departure, client with link + email, operational status with latest-transition attribution, attention flags) including the expected first payment / balance split: once payment is initiated the persisted deposit_amount/balance_amount are shown; before that, the split the client will be asked to pay is derived from the checkout snapshot via PaymentCalculatorService and labelled “expected”.

The Cost Breakdown section presents the fixed client price from four separate angles, all computed by a single DTO (BookingPricingData, memoized per render) from stored data only — booking columns, the checkout snapshot, persisted upsell costs, the issued insurance contract and ticketed flight legs; nothing is re-priced live:

  • P&L summary — fixed client revenue, final supplier costs at their best-known real value, actual gross margin (absolute + %), the initially quoted margin and the movement between them.
  • Customer payments — the deposit/balance collection schedule (expected split when unpaid).
  • How the price was built — the quote-time decomposition: flight baseline + land + included insurance → base cost → margin + marketing rounding → base price → extras at retail → client price.
  • Direct supplier costs — per-concept cost lines (flight at the checkout-committed price with ticketing status, land with composition and FX exposure, insurance counted once at its real cost, extras at captured supplier cost) reconciling to the total; each line links to its Arkana record (supplier services, tour, insurance product, flight logs).

A Flight Cost Detail section compares the four flight price stages (offer baseline → pricing baseline at actual pax → seen at checkout → final ticketed) with per-leg purchase→ticketing variation, so margin absorption and reprices are explicit. Cost/margin surfaces are hidden from supplier managers (GatesSupplierManagerPrices), who cannot open the booking view at all today — the gating is defense-in-depth.

Cost sourcing rules: land and insurance components prefer the land_price/insurance_price persisted in the checkout snapshot (immutable purchase-time data; the offer derivation is only a legacy fallback), the included insurance’s real cost prefers the issued contract net price → persisted upsell cost_price → snapshot estimate, and a business-class booking’s flight line carries the full business fare while the upgrade extra carries zero cost (never double-counted).

Source: backend/app/Filament/Resources/Bookings/Schemas/BookingPricingData.php, BookingSummarySection.php, BookingCostBreakdownSection.php, BookingFlightCostSection.php

Shows checkout progression for bookings with tracking data. Includes:

  • Furthest Step badge with color coding (the highest step reached, from checkout_step)
  • Checkout started/elapsed time with completion timestamp
  • Draft expiration date
  • Visual step timeline via custom CheckoutFunnelEntry component — shows “(active)” on the step the user is currently viewing (from checkout_snapshot['current_step']), while reached/completed steps are determined by furthest step
  • Contact info row (name, email, phone) for quick agent outreach on abandoned drafts

Only visible when checkout_started_at is set or checkout_step is not Completed.

Source: backend/app/Filament/Resources/Bookings/Schemas/CheckoutFunnelSection.php

The infolist renders the selected extras in one of two sections, depending on whether the booking has been finalized:

  • Extras & Upgrades — sourced from the persisted booking_upsells rows. Only present once the booking is finalized (rows are created at payment finalization). Shows type, item name, day, unit price, quantity, and total.
  • Selected Extras (in checkout) — shown for in-progress bookings that have no booking_upsells rows yet but carry selections in their checkout payload (checkout_session_data, falling back to checkout_snapshot). Built by Booking::checkoutExtrasBreakdown(), it lists each selected extra with its type, name (plus location), day, and price.

The breakdown’s per-line prices mirror the finalization rules so they reconcile with the booking’s extras_price: each activity is multiplied by its own per-activity participant count (participants, falling back to number_of_travelers for legacy selections saved before per-activity counts existed) and the business flight upgrade is multiplied by number_of_travelers, while hotels are per-room, and transfers and insurance are per-trip/whole-party. Hotel upgrades render a night range (e.g. 1-3) from nights_start/nights_end, collapsing to a single value when they coincide. The section is hidden when no extras were selected.

Source: backend/app/Filament/Resources/Bookings/Schemas/BookingInfolist.php, backend/app/Models/Booking.php (checkoutExtrasBreakdown())

The View page includes a collapsible Flight section that displays cabin class, outbound/inbound legs (route, date, times, flight numbers, airlines, stops), and FlightBooking statuses (booking, ticket, source). If any FlightBooking is linked, a header action links to its detail page.

Per-Leg Breakdown: When flight_selection contains a legs array, a “Per-Leg Breakdown” subsection renders an individual Section per leg with heading “Leg N: Type – Route”. International and domestic legs have distinct layouts:

  • International legs: Summary row (type, cabin, route, raw fare price per pax) plus outbound/inbound direction rows showing schedule (date + departure/arrival times), flight numbers with airline names, and stops with stopover IATA codes
  • Domestic legs: Summary row (type, cabin, route, raw fare price per pax) plus a one-way direction row showing schedule, flight numbers with airline names, and stops with stopover IATA codes
  • Direction rows and fare price are hidden when the leg is booked – a “View” header action links to the FlightBooking detail page instead
  • If not booked: “In progress…” badge when flight_booking_in_progress, or error message from the last failed transition metadata (matched by leg_index)
  • Per-leg actions: Each unbooked leg has its own “Book Flight” or “Retry” header action that dispatches a single CreateCheckoutFlightBookingJob for that leg

Fallback: Economy bookings without a legs array show a “Flight Details” section with outbound/inbound direction rows (route, schedule, flights, stops) instead of per-leg breakdown.

Shows payment summary and individual payment records. Visible when deposit_amount is set or any Payment records exist for the booking.

Summary grid (split-payment bookings only): Deposit amount, balance due date, total paid (computed from succeeded payments only), and remaining amount with color indicators (green when fully paid, red when outstanding).

Payments table: Lists all payment records regardless of status (Succeeded, Pending, Failed, Refunded, Cancelled) with type badge, amount with currency, status badge, method (gateway name for online payments, the offline method plus its bank reference for confirmed transfers), card info, date, receipt download and who confirmed it. Abandoned pending attempts are automatically closed as Cancelled / superseded when a newer attempt of the same type is generated or one succeeds, so the table stays readable.

Total paid is computed from the eager-loaded payments collection filtered by PaymentStatus::Succeeded, avoiding extra queries.

Payment progress: A segmented counter (0/21/22/2, or 1/1 on single-payment plans) sits at the end of both the Booking Summary and Payments section headers, counting plan instalments covered by succeeded payments of any channel. The Attention flag stops reporting Payment pending once nothing is outstanding.

Payment actions: Two header actions sit side by side — Generate Payment Link, which sends the customer a link for the booking’s current state (deposit/full for pending_payment, balance for awaiting_balance, with an optional email via BalancePaymentRequestNotification), and Confirm Payment (n/n), which records money already collected outside the checkout: a bank transfer or a card charged by hand, with a mandatory receipt. See Admin Payment Links and Offline Payments.

Source: backend/app/Filament/Resources/Bookings/Schemas/BookingPaymentSection.php, backend/app/Filament/Resources/Bookings/Pages/ViewBooking.php

Flight data is resolved from three sources in priority order via BookingFlightData:

  1. flight_selection JSON (checkout bookings) - richest data with enriched airports, airline names (preferred over airline codes), and per-leg breakdown
  2. FlightBooking model (admin-created or manually linked) - uses booking fields directly
  3. Offer flight cache (legacy/admin fallback) - reconstructs from DynamicFlightCache segments

This means every booking shows flight details regardless of how it was created.

Source: backend/app/Filament/Resources/Bookings/Schemas/BookingFlightSection.php, backend/app/Filament/Resources/Bookings/Schemas/BookingFlightData.php

Bookings can include optional extras (upsells) from the tour itinerary. These are fetched and priced by BookingUpsellPriceService.

Type Pricing Description
Hotel Per room Price difference between guaranteed and upgrade hotel, one upsell row per room (quantity = 1)
Activity Per participant Unit price × the activity’s per-participant count (participants, falling back to number_of_travelers for legacy selections)
Transfer Flat per trip Single price for the transfer (per-person luxury transfers bill one unit per traveller)
Insurance Per policy Travel insurance policy price
Flight Upgrade Per person Business class upgrade cost × number of travelers

Flight upgrades store selection data in flight_search_params JSON for later verification:

{
"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": {
"departure_date": "2026-02-22",
"departure_time": "08:00",
"arrival_time": "14:30",
"departure_airport": "NBO",
"arrival_airport": "MAD",
"flight_numbers": ["EK789"],
"airlines": [{"code": "EK", "name": "Emirates"}],
"stops": 0,
"stopover_airports": [],
"arrivalDayOffset": 0
},
"apihubflowid": "4844a05b-3ce2-45b1-b907-1669e574e94c"
}

This data enables:

  • Re-searching to verify flight availability
  • Historical reference for flight details
  • Tracking via apihubflowid (Aerticket API Hub flow ID for booking lookup)

Upsells are sourced from the offer’s tour itinerary:

  1. Service fetches itinerary days with their optional hotels, activities, transfers
  2. Prices are calculated using rate periods covering the departure date
  3. Only upsells with valid rates for the date are shown

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

Source: backend/app/Models/BookingUpsell.php

$upsell->booking; // BelongsTo Booking
$upsell->hotel; // BelongsTo SupplierHotel (if type=hotel)
$upsell->activity; // BelongsTo SupplierActivity (if type=activity)
$upsell->transfer; // BelongsTo SupplierTransfer (if type=transfer)
$upsell->insurance; // BelongsTo SupplierInsurance (if type=insurance)
$upsell->getItem(); // Returns supplier entity (null for flight_upgrade)
$upsell->getName(); // Returns display name or flight summary
$upsell->getFlightSummary(); // Returns "MAD → NBO / NBO → MAD" for flights

Flight upgrade display: getName() returns "Business Class Upgrade". Route details are available via getFlightSummary() which returns route summary like MAD → NBO / JNB → MAD (supports open-jaw flights where return airport differs).

When a customer completes checkout payment, BookingFinalizationService populates the booking:

  1. Passengers - Created from checkout session’s traveler_data, attached via booking_passenger
  2. Flight Selection - Economy and business flights stored in flight_selection column with enriched airport data
  3. Upsells - Created from session’s hotel/activity/transfer/flight selections using session’s actual_pax_count
  4. Flight Upgrades - If business class selected (business_extra_price_per_person > 0), stores flight data in booking_upsells.flight_search_params

Variable passenger count: The finalization service persists each upsell at its committed price:

  • Hotels — one upsell row per room from the session’s hotel_selections, each storing that room’s price_difference at quantity = 1. The per-room price_difference already reflects the full room_configuration (the sum of upgradePrice(type) × count across the room’s guests), so hotel pricing is no longer a flat 2A rate (#2191).
  • Activities — priced per participant: participants = activity['participants'] ?? number_of_travelers, quantity = participants, total_price = unit_price × participants (#2209/#2210). Legacy sessions without a per-activity count fall back to the full party.
  • Flight upgrades — business class upgrade price × number_of_travelers.

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

This happens automatically after successful payment in PaymentController::confirm(). After finalization completes, the checkout session is cleared via CheckoutSessionService::clear() to prevent session reuse.

After successful payment, booking status is routed by PaymentService::updateBookingPaymentStatus():

  • Flight bookings (cabin_class = ECONOMY / BUSINESS) transition to pending_flight_booking
  • Land-only bookings transition to pending_land_confirmation

Flight bookings are operator-controlled in Filament. Each flight leg is booked independently:

  1. pending_flight_booking -> Book All Flights (multi-leg) or Book Flight (single-leg) dispatches one CreateCheckoutFlightBookingJob per unbooked leg and transitions to flight_booking_in_progress
  2. Each job creates its own FlightBooking record with independent PNR, status, and retrieve flow
  3. Only when ALL legs have FlightBooking records does the booking transition to flights_confirmed
  4. Job failure transitions to flight_booking_failed with structured error metadata including leg_index (see below)
  5. flight_booking_failed -> Retry All Flights (multi-leg) or Retry (per-leg) re-dispatches jobs for unbooked legs only
  6. Admin clicks Confirm Services → booking moves to awaiting_balance (if balance due) or fully_paid (if paid in full)

All transitions are recorded in booking_status_transitions and shown in the booking Status Timeline on the view page.

Failure metadata structure:

When a flight booking fails, the metadata JSON on the transition includes structured error context from CheckoutFlightBookingException::$context, spread alongside the base fields:

  • error_type - Failure classification from CheckoutFlightBookingFailure enum (e.g., no_matching_fare, verify_failed, booking_failed)
  • message - Human-readable error reason (displayed in the status timeline UI)
  • leg_index - Which flight leg failed (0-based)
  • attempt - Which job attempt failed
  • sub_type - Aerticket-specific error category (e.g., price_change, fare_expired, timeout, api_error)
  • Additional fields vary by sub_type: original_price/new_price/currency for price changes, fare_id for verify/booking errors, provider_errors for API failures, validation_errors for validation failures

Status timeline display: The Blade component (booking-status-timeline-entry.blade.php) renders the message field from failed transition metadata directly under the status badge, so admins can see the failure reason without opening logs.

Source: backend/app/Services/Booking/BookingStatusService.php, backend/app/Jobs/CreateCheckoutFlightBookingJob.php, backend/app/Filament/Resources/Bookings/Pages/ViewBooking.php

Related: Checkout API - Payment Flow

Flight selections (both economy and business) are stored directly on the booking with enriched data:

{
"cabin_class": "BUSINESS",
"outbound": {
"departure_date": "2026-03-15",
"departure_time": "10:30",
"arrival_time": "22:45",
"departure_airport": { "iata_code": "MAD", "city": "Madrid" },
"arrival_airport": { "iata_code": "CMB", "city": "Colombo" },
"flight_numbers": ["UX1234", "EK456"],
"airlines": [{"code": "UX", "name": "Air Europa"}, {"code": "EK", "name": "Emirates"}],
"stops": 1,
"stopover_airports": [{ "iata_code": "DXB", "city": "Dubai" }],
"total_duration_minutes": 735,
"arrivalDayOffset": 1
},
"inbound": { "..." : "..." },
"business_extra_price_per_person": 450.00,
"legs": [
{
"leg_index": 0,
"type": "international",
"cabin_class": "BUSINESS",
"fare_price": 1250.00,
"flight_numbers": ["UX1234", "EK456"],
"route": "MAD-CMB-MAD"
},
{
"leg_index": 1,
"type": "domestic",
"cabin_class": "ECONOMY",
"fare_price": 85.00,
"cached_price": 85.00,
"flight_numbers": ["1582"],
"route": "CMB-TRZ",
"departure_date": "2026-03-16",
"departure_time": "14:30",
"arrival_time": "15:45",
"airline_names": ["SriLankan Airlines"],
"stopover_airports": []
}
]
}

The service enriches raw session data by:

  • Looking up airport city names from database
  • Resolving airline IATA codes to {code, name} objects
  • Calculating total flight duration (handles overnight flights)
  • Computing arrivalDayOffset (days between departure and arrival, e.g. 1 for overnight flights)
  • Structuring stopovers with city information

Both economy and business class selections include a per-leg breakdown in the legs array. Business legs are built by CheckoutSessionService::enrichWithLegsBreakdown() and economy legs by buildEconomyLegs(). Each leg records its type (international/domestic), leg_index, cabin class, fare_price (per-person), flight_numbers, and route. Domestic legs additionally include departure_time, arrival_time, airline_names (full names), and stopover_airports (IATA codes) extracted from cached flight segments during search. Economy legs use actual API-searched prices stored in session metadata (international_fare_price and per-leg searched_price) rather than deriving by subtraction, with fallback to the old subtraction method for backward compatibility. Domestic legs that fell back to ECONOMY (no business fare available) include a cached_price field with the economy search price. Per-leg fare_price is used during checkout flight booking for price matching (fare_price * pax_count compared to Aerticket fare->totalPrice).

Business class selections are also stored in the BookingUpsell record (type flight_upgrade) for financial tracking.

Bookings automatically trigger client.updateBookingAnalytics() on save to keep client statistics current.