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.
Data Model
Section titled “Data Model”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 UpgradesDatabase Schema
Section titled “Database Schema”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_idis set, others nulltype='activity'→supplier_activity_idis set, others nulltype='transfer'→supplier_transfer_idis set, others nulltype='insurance'→supplier_insurance_idis set, others nulltype='flight_upgrade'→ No FK, usesflight_search_paramsJSON 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
Status Lifecycle
Section titled “Status Lifecycle”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()).
Manual Status Overrides
Section titled “Manual Status Overrides”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_transitionsrow records the acting user, the moment, a mandatory non-blank reason (validated in the domain service, not just the form), andmetadata.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
FullyPaidmanually — but when payments do not cover the total, the shortfall is stamped into the audit row asmetadata.unpaid_at_overrideand 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 Funnel Tracking
Section titled “Checkout Funnel Tracking”Checkout-flow bookings (draft / checkout) track which checkout step each user reaches, enabling drop-off analysis.
Funnel Steps
Section titled “Funnel Steps”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
How Tracking Works
Section titled “How Tracking Works”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:
trackStep()– Called via the internal API (PATCH /api/internal/bookings/{id}/checkout-step) during Astro SSR page rendering. Advancescheckout_stepforward-only. Always updatescheckout_snapshot['current_step']to the actual step being viewed (even on backward navigation). Records a timestamp instep_timestampsif one doesn’t already exist for that step.advanceStep()– Called on PUT selection endpoints (hotels, activities, transfers). Records a step timestamp incheckout_snapshot, syncs pricing from the session, and advancescheckout_stepforward-only as a fallback when SSR tracking fails. Only called on forward navigation (controlled by theadvancerequest flag, defaults totrue). Flights, contact, and travelers endpoints always calladvanceStep.
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
Checkout Snapshot
Section titled “Checkout Snapshot”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 Expiration
Section titled “Checkout Expiration”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, envBOOKING_QUOTATION_EXPIRATION_DAYS)
Config: backend/config/booking.php
Booking Reference
Section titled “Booking Reference”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
Relationships
Section titled “Relationships”$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 BookingStatusTransitionStatus Methods
Section titled “Status Methods”$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.
Filament Admin
Section titled “Filament Admin”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
Creating a Booking
Section titled “Creating a Booking”- Select an active Offer
- Select a Client
- Attach passengers from client’s saved passengers
- Designate one lead passenger
- Select optional extras (hotels, activities, transfers)
- Review pricing (base + extras = total)
Edit Restrictions
Section titled “Edit Restrictions”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
View Page - Checkout Funnel Section
Section titled “View Page - Checkout Funnel Section”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
CheckoutFunnelEntrycomponent — shows “(active)” on the step the user is currently viewing (fromcheckout_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
View Page - Extras Sections
Section titled “View Page - Extras Sections”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_upsellsrows. 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_upsellsrows yet but carry selections in their checkout payload (checkout_session_data, falling back tocheckout_snapshot). Built byBooking::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())
View Page - Flight Section
Section titled “View Page - Flight Section”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 byleg_index) - Per-leg actions: Each unbooked leg has its own “Book Flight” or “Retry” header action that dispatches a single
CreateCheckoutFlightBookingJobfor 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.
View Page - Payment Section
Section titled “View Page - Payment Section”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/2 → 1/2 → 2/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:
flight_selectionJSON (checkout bookings) - richest data with enriched airports, airline names (preferred over airline codes), and per-leg breakdownFlightBookingmodel (admin-created or manually linked) - uses booking fields directly- Offer flight cache (legacy/admin fallback) - reconstructs from
DynamicFlightCachesegments
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
Upsells
Section titled “Upsells”Bookings can include optional extras (upsells) from the tour itinerary. These are fetched and priced by BookingUpsellPriceService.
Upsell Types
Section titled “Upsell Types”| 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 Upgrade Data
Section titled “Flight Upgrade Data”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)
Price Calculation
Section titled “Price Calculation”Upsells are sourced from the offer’s tour itinerary:
- Service fetches itinerary days with their optional hotels, activities, transfers
- Prices are calculated using rate periods covering the departure date
- Only upsells with valid rates for the date are shown
Source: backend/app/Services/Checkout/BookingUpsellPriceService.php
BookingUpsell Model
Section titled “BookingUpsell Model”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 flightsFlight 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).
Checkout Finalization
Section titled “Checkout Finalization”When a customer completes checkout payment, BookingFinalizationService populates the booking:
- Passengers - Created from checkout session’s
traveler_data, attached viabooking_passenger - Flight Selection - Economy and business flights stored in
flight_selectioncolumn with enriched airport data - Upsells - Created from session’s hotel/activity/transfer/flight selections using session’s
actual_pax_count - Flight Upgrades - If business class selected (
business_extra_price_per_person > 0), stores flight data inbooking_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’sprice_differenceatquantity = 1. The per-roomprice_differencealready reflects the fullroom_configuration(the sum ofupgradePrice(type) × countacross 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.
Status Routing After Payment
Section titled “Status Routing After Payment”After successful payment, booking status is routed by PaymentService::updateBookingPaymentStatus():
- Flight bookings (
cabin_class=ECONOMY/BUSINESS) transition topending_flight_booking - Land-only bookings transition to
pending_land_confirmation
Admin-Driven Flight Booking (Per-Leg)
Section titled “Admin-Driven Flight Booking (Per-Leg)”Flight bookings are operator-controlled in Filament. Each flight leg is booked independently:
pending_flight_booking-> Book All Flights (multi-leg) or Book Flight (single-leg) dispatches oneCreateCheckoutFlightBookingJobper unbooked leg and transitions toflight_booking_in_progress- Each job creates its own
FlightBookingrecord with independent PNR, status, and retrieve flow - Only when ALL legs have
FlightBookingrecords does the booking transition toflights_confirmed - Job failure transitions to
flight_booking_failedwith structured error metadata includingleg_index(see below) flight_booking_failed-> Retry All Flights (multi-leg) or Retry (per-leg) re-dispatches jobs for unbooked legs only- Admin clicks Confirm Services → booking moves to
awaiting_balance(if balance due) orfully_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 fromCheckoutFlightBookingFailureenum (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 failedsub_type- Aerticket-specific error category (e.g.,price_change,fare_expired,timeout,api_error)- Additional fields vary by
sub_type:original_price/new_price/currencyfor price changes,fare_idfor verify/booking errors,provider_errorsfor API failures,validation_errorsfor 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 Selection Storage
Section titled “Flight Selection Storage”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.1for 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.
Client Analytics
Section titled “Client Analytics”Bookings automatically trigger client.updateBookingAnalytics() on save to keep client statistics current.
Related
Section titled “Related”- Checkout API - Checkout flow, funnel tracking internal API, and finalization
- Offers - Offer creation and pricing
- Product Relationships - Product entity structure
- Suppliers - Supplier hotels, activities, transfers