Booking Lifecycle
Service layer that moves a booking through its lifecycle: a forward-only status state machine with a full audit trail, post-payment finalization (passengers, upsells, allotment, insurance), and a channel-attribution touchpoint trail.
This page documents the services. The status enum table, ASCII flow diagram and bookings schema live in Bookings (database) — read that first for the data model.
When to Use
Section titled “When to Use”- Any code that changes a booking’s
status— always go throughBookingStatusService, never$booking->update(['status' => ...]) - Payment success handlers that need to turn a paid checkout session into a real booking (finalization)
- Admin tooling that needs rollbacks or corrections outside the normal flow (
override()) - Reporting on how a booking was sold (attribution trail)
Status State Machine
Section titled “Status State Machine”BookingStatus (backend/app/Enums/BookingStatus.php) is a 17-state enum covering the whole lifecycle: 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.
Transitions are forward-only and whitelisted per state in BookingStatus::allowedTransitions(); Completed, Cancelled and Expired are terminal (empty transition list). BookingStatusService::transition() rejects anything not on the whitelist.
Helper predicates on the enum drive behavior elsewhere: isFinal(), requiresPayment(), requiresAdminAction(), isQuotation(), isInCheckoutFlow(), isPrePayment() and requiresPassengers().
transition()
Section titled “transition()”BookingStatusService::transition(Booking, BookingStatus, ?User, ?string $notes, ?array $metadata):
- Wraps everything in a DB transaction and re-loads the booking with
lockForUpdate()(no concurrent double-transitions). - Rejects the move if
canTransitionTo()says no (InvalidArgumentException). - Passenger guard: states where
requiresPassengers()is true (the flight pipeline, land pipeline and settled states) cannot be entered while the booking has no passengers attached. This blocks the production failure mode where an admin advanced a passenger-less booking into the flight pipeline and the Aerticket integration crashed on hardcoded fallback data. - Writes a
BookingStatusTransitionaudit row:from_status,to_status, actinguser_id(null for system moves),notes, structuredmetadata,transitioned_at. - Updates the booking and records a
StatusChangedattribution touchpoint. - On
PaymentProcessing → PendingFlightBooking | PendingLandConfirmationit fires the booking-submitted emails to Volāre and the tour’s suppliers — deliberately at this point, after finalization has committed the passengers, so the emails render real pax counts. Suppliers without panel users fall back to their operational email; a supplier with no reachable address is reported. See Notifications for the email details.
recordInitialStatus() writes the first audit row (from_status = null) when a booking is created. canTransition() checks validity without side effects.
override()
Section titled “override()”Manual escape hatch for rollbacks and mis-clicked final states, outside the forward-only whitelist:
- Requires a non-blank reason (validated in the service, not just the Filament form).
- The passenger invariant still applies —
requiresPassengers()states cannot be forced onto a passenger-less booking. - May move a booking to
FullyPaid/Completedwithout gateway payments covering the total (offline bank transfers, manual recoveries), but any shortfall is stamped into the audit row asmetadata.unpaid_at_overrideand logged. - The audit row carries
metadata.manual_override = true, and the touchpoint description is flagged as a manual override.
In Filament, the booking Edit page routes in-flow changes through transition() and out-of-flow changes through override() — see Manual Status Overrides.
Finalization
Section titled “Finalization”BookingFinalizationService::finalizeFromCheckoutSession(Booking, array $session) turns a paid checkout session into a complete booking. Called from PaymentController (Stripe) and RedsysPaymentFinalizationService between the PaymentProcessing transition and the post-payment routing transition.
Inside one locked DB transaction (idempotent — skips if passengers already exist):
- Consume allotment via
AllotmentService::consumeAllotment()(throwsInsufficientAllotmentExceptionif a service date is full) — see Allotment - Create
Passengerrecords fromtraveler_data(durable booking column preferred over the session snapshot), deriving age category from DOB and defaulting passport/residence country to nationality - Attach passengers to the booking (first = lead) and to the client (first = self / primary contact)
- Persist the full flight selection payload (fare_id, segments, prices — needed later by the booking-time flight matcher), enriched with airport cities and durations
- Create
BookingUpsellrows for hotels (per room), activities (per person × chosen participants), transfers (per trip), luxury transfers, business-class flight upgrades (per person) and insurance (whole-party policy, never multiplied by pax) - Persist a pending
InsuranceContractwhen insurance was selected
Two things deliberately run outside the transaction: the included base insurance backfill (ensureIncludedInsurance() makes a live Intermundial quote and degrades gracefully on failure) before it, and ContractInsuranceJob (the irreversible policy emission) after commit — an Intermundial failure must never roll back an already-paid booking.
Prices are not computed here — base_price, extras_price and total_amount were already set in PaymentController::createIntent() before payment.
Attribution Trail
Section titled “Attribution Trail”BookingAttributionService maintains an audit trail of BookingTouchpoint rows (types: checkout_started, contact_captured, agent_interaction, status_changed, payment_processed, admin_booking_created, attribution_overridden, preview_viewed, checkout_link_opened). Every status change records a touchpoint automatically; descriptions are generated from per-type templates with metadata substitution.
computeAttribution() is a deterministic last-touch algorithm resolving a BookingChannel:
- Booking created by an admin user →
AdminCreated - Agent touchpoints with a
phone_sales/in_personchannel hint → that channel - Agent touchpoints without a hint →
SalesAssisted - No agent touchpoints →
WebSelfService
The agent credited is the last agent to touch the booking. computeAndLock() freezes the result on the booking (idempotent; called on payment success with lock reason payment_succeeded, also recording a PaymentProcessed touchpoint). After locking, updateAttribution() throws AttributionLockedException; only adminOverrideAttribution() can change it, re-locking with reason admin_override and a mandatory justification recorded as a touchpoint.
Business Rules
Section titled “Business Rules”- Status changes are forward-only; only
override()(audited, reasoned) may break the whitelist - Paid/post-finalization states require at least one passenger — no exceptions, not even for overrides
- Every status change produces both a
BookingStatusTransitionrow and aStatusChangedtouchpoint - Finalization is idempotent and allotment-guarded; insurance emission is out-of-band and can never block the booking
- Attribution locks at first payment and stays immutable except by admin override
Related
Section titled “Related”- Bookings (database) — schema, status table, flow diagram, funnel tracking
- Checkout API — the flow that drives these transitions
- Notifications — booking-submitted emails
- Allotment — inventory consumed at finalization
- Source:
backend/app/Services/Booking/BookingStatusService.php - Source:
backend/app/Services/Booking/BookingFinalizationService.php - Source:
backend/app/Services/Booking/BookingAttributionService.php - Source:
backend/app/Enums/BookingStatus.php