Skip to content

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.

  • Any code that changes a booking’s status — always go through BookingStatusService, 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)

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().

BookingStatusService::transition(Booking, BookingStatus, ?User, ?string $notes, ?array $metadata):

  1. Wraps everything in a DB transaction and re-loads the booking with lockForUpdate() (no concurrent double-transitions).
  2. Rejects the move if canTransitionTo() says no (InvalidArgumentException).
  3. 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.
  4. Writes a BookingStatusTransition audit row: from_status, to_status, acting user_id (null for system moves), notes, structured metadata, transitioned_at.
  5. Updates the booking and records a StatusChanged attribution touchpoint.
  6. On PaymentProcessing → PendingFlightBooking | PendingLandConfirmation it 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.

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/Completed without gateway payments covering the total (offline bank transfers, manual recoveries), but any shortfall is stamped into the audit row as metadata.unpaid_at_override and 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.

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):

  1. Consume allotment via AllotmentService::consumeAllotment() (throws InsufficientAllotmentException if a service date is full) — see Allotment
  2. Create Passenger records from traveler_data (durable booking column preferred over the session snapshot), deriving age category from DOB and defaulting passport/residence country to nationality
  3. Attach passengers to the booking (first = lead) and to the client (first = self / primary contact)
  4. Persist the full flight selection payload (fare_id, segments, prices — needed later by the booking-time flight matcher), enriched with airport cities and durations
  5. Create BookingUpsell rows 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)
  6. Persist a pending InsuranceContract when 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.

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:

  1. Booking created by an admin user → AdminCreated
  2. Agent touchpoints with a phone_sales/in_person channel hint → that channel
  3. Agent touchpoints without a hint → SalesAssisted
  4. 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.

  • 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 BookingStatusTransition row and a StatusChanged touchpoint
  • 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
  • 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