Skip to content

Offers

An Offer is a bookable travel package that combines one or more flights with a land component (tour). Each offer has a unique SKU, calculated pricing, and lifecycle status.

Offers are created through a 3-step wizard in the admin panel.

Select the product configuration:

  • Product by Market: The product and target market (e.g., “India fun 8 days” for Spain)
  • Tour Rate: Date period with pricing (e.g., Mar 1 - Jul 31, 2026)
  • Room Type: Passenger configuration (e.g., “2 Adults”)

Choose flight source using the toggle:

Cached Flights (default) - Select from pre-cached flight pricing:

  • Filtered to match tour rate period dates
  • Filtered to allowed travel weekdays (e.g., Fri, Sat, Sun)
  • Blackout dates excluded

Manual Entry - Enter externally purchased flights (charter, direct airline bookings):

  • Select departure/arrival airports
  • Enter flight times for outbound and return
  • Optionally specify flight number and notes
  • Creates a FlightBooking with source: manual

Source: backend/app/Enums/FlightBookingSource.php defines api | manual | checkout values.

For products with domestic legs (e.g., international flight + internal domestic flight), the wizard shows a unified table with both flight types:

  • International flights: Cross-border flights (main journey)
  • Domestic flights: Same-country internal flights (e.g., Delhi to Goa)

Selection Rules:

  • Only ONE flight per leg type can be selected
  • All required legs must be selected before proceeding
  • Domestic flights are matched to their route based on the flight config
  • The wizard auto-detects leg type from the route’s is_domestic flag

Validation:

  • If multiple flights of the same leg type are selected, an error is shown
  • Missing legs are listed in the notification when selection is incomplete

Source: backend/app/Filament/Resources/Offers/Pages/CreateOffer.php:848-927

Preview offer before creation:

  • All selected flight legs with individual prices
  • Tour Services breakdown (hotels + included activities with prices)
  • Combined total and margin calculation

The offer detail page shows all components organized in sections.

Basic identification and dates:

  • SKU: Unique identifier (e.g., ES-173-10-ES1-MAD-260301-01)
  • Status: Draft or Active
  • Number of Pax: Passenger count (e.g., 2)
  • Departure/Return Dates: Trip window

Shows the price breakdown and margin calculation:

FieldExampleDescription
Flight Price691.99Flight cost for all passengers
Land Price388.00Tour cost for all passengers
Total Base Price1,079.99Combined flight + land
Margin20%Markup percentage
Price per Person650.00Per-pax price (rounded to nearest 10)
Final Price1,300.00Total price (per-pax × pax count)

Tour details when a rate is linked:

  • Supplier: Tour provider (e.g., Condor Travel)
  • Tour: Product name (e.g., India fun 8 days)
  • Room Type: Selected configuration
  • Rate Period: Valid date range
  • Travel Window: Allowed weekdays
  • Allotment: Used vs. total capacity

Services breakdown showing individual prices for the selected room type. Prices are displayed in the service currency (e.g., JPY, USD) — not the market currency. Currency conversion to the market currency happens at the offer level when saving.

Service TypeIconPrice Calculation
Hotel🏨Per-night × nights at location
Activity🎫Per-person × number of travelers

Only included activities appear here. Upsell activities are optional upgrades not in the base price.

Source: backend/app/Filament/Resources/Offers/Schemas/OfferPreviewData.php (getTourServicesBreakdown())

Day-by-day hotel schedule showing:

  • Day number with actual date
  • Destination city
  • Guaranteed hotel (default, included in base price)
  • Upsell hotel (premium upgrade option)

Link to the associated product configuration with market and status.

Flight details from cache or manual booking. For multi-leg offers, shows each leg:

FieldDescription
Flight TypeInternational or Domestic
RouteAirport codes (e.g., MAD-DEL-BKK)
Departure DateFlight departure
PriceCost for this leg
CUG TypeFare category
Sourcecache or manual

Important: Offers ALWAYS store 2A (2 adult) pricing and are never mutated based on actual passenger count. The offer’s final_price is the baseline for checkout pricing.

Total Flight Price = Sum of all flight leg prices (for 2 pax)
Land Price = Package Service price (if set) OR Σ(Hotel Services) + Σ(Included Activity Services)
Base Price = Total Flight Price + Land Price
Raw Total = Base Price × (1 + Margin%)
Per-Pax Price = roundToMarketingPrice(Raw Total / Pax Count)
Final Price = Per-Pax Price × Pax Count

When a customer selects a non-2A room type during checkout (e.g., 3 passengers), the checkout session recalculates pricing:

  1. Flight prices scale linearly: (offer.flight_base_price / 2) × actual_pax_count
  2. Land prices are recalculated: AutoOfferGeneratorService::calculateLandPrice(tour, actual_room_type, departure_date)
  3. Per-pax marketing rounding is applied: Same rounding logic as offers, but with actual pax count
  4. Hotel upgrade extras use actual room type pricing: Price differences are calculated using the session’s actual_room_type, so hotels without pricing for the selected room type return null (unavailable)

The offer itself remains unchanged at 2A pricing. The checkout session is the source of truth for actual passenger count and pricing.

Prices are rounded per person first, then multiplied back to get the total. This ensures clean per-person prices on the website (e.g., “€2,370/persona” instead of “€2,374.72/persona”).

Example calculation (2 pax):

Base Price: €3,957.86 (for 2 pax)
With 20% margin: €3,957.86 × 1.20 = €4,749.43 total
Per-pax raw: €4,749.43 / 2 = €2,374.72
Per-pax rounded: €2,370 (nearest multiple of 10)
Final price: €2,370 × 2 = €4,740

Example calculation (3 pax, checkout):

Base Price: €5,936.79 (flight + land for 3 pax, recalculated)
With 20% margin: €5,936.79 × 1.20 = €7,124.15 total
Per-pax raw: €7,124.15 / 3 = €2,374.72
Per-pax rounded: €2,370 (nearest multiple of 10)
Final price: €2,370 × 3 = €7,110

The marketing_price_per_pax field stores the clean per-person price for display on the website.

Pax count is derived from room_type (e.g., “2A” → 2 pax, “2A+1CH” → 3 pax). Defaults to 2 when room_type is null. For offers, this is ALWAYS 2A.

Land price uses one of two models, determined by the supplier tour’s package_service_id:

Package Service (when package_service_id is set): A flat price from the package service’s rate for the room type and date. Individual hotel/activity prices are ignored.

Itemized Pricing (when no package service): Sum of individual service prices:

ComponentCalculationRoom Type Lookup
Hotel Servicesrate_price × nightsSelected room type (e.g., 2A)
Activity Servicesrate_price × travelersAlways per_person

Note: Only included activities are counted. Upsell activities are optional and not in base price.

Offer prices round to the nearest multiple of 10 with a delayed thousand jump:

  • €2,374.72 → €2,370 (nearest 10)
  • €996 → €990 (delayed: rounded 1000 falls in [1000, 1070), clamped to 990)
  • €1,023 → €990 (delayed: rounded 1020 falls in [1000, 1070), clamped to 990)
  • €1,078 → €1,080 (normal: 1080 is outside the delay zone)

Delayed jump rule: When the rounded value lands in [X000, X070) for X >= 1, it clamps to X000 - 10 (e.g., 990, 1990, 2990). This avoids premature visual jumps to the next thousand.

Activity, hotel, and transfer extras use Offer::roundToDisplayPrice() which rounds to the nearest multiple of 10 without the delayed jump rule.

Source: backend/app/Models/Offer.php (roundToMarketingPrice(), roundToDisplayPrice())

Pattern: <ProductByMarket SKU>-<Airport>-<Date>-<Sequence>

Example: ES-173-10-ES1-MAD-260301-01

PartValueMeaning
ES-173-10-ES1ProductByMarket SKUSpain, Product 173, 10 days, template 1
MADAirport IATAMadrid departure
260301YYMMDDMarch 1, 2026
01SequenceFirst offer for this combination
StatusEditableDescription
DraftYes (margin only)Work in progress
ActiveNoPublished and locked

Once active, offers cannot be modified. Create a new offer instead.

An Active offer is not necessarily bookable by customers. The bookable() query scope filters to offers that are both Active AND have a departure date at least 5 days in the future.

Why: Offers with past or near-future departures caused checkout failures (e.g., flight search for past dates returning 422 errors). The 5-day lead time ensures enough time for flight booking logistics after a customer completes checkout.

Constant: Offer::BOOKING_LEAD_TIME_DAYS = 5

Scope: Offer::query()->bookable() applies status = Active AND departure_date >= today + 5 days.

Where it’s used:

  • All customer-facing checkout endpoints (9 queries in CheckoutController)
  • Trip configurator endpoint (ProductByMarketController::configurator())
  • Leading price calculation (ProductByMarket::getLeadingPrice())
  • Bookable offers count (ProductByMarket::getBookableOffersCount())

Admin visibility: The Offers table in Filament includes a “Bookability” ternary filter that lets admins see “Bookable” vs “Expired for sale” offers.

Checkout start (410 Gone): When starting checkout, if a bookable offer is not found but an Active offer exists for that ID in the same market, the API returns 410 Gone with error: "offer_expired" instead of 404. This lets the frontend show a specific “offer expired” message rather than a generic “not found”.

Source: backend/app/Models/Offer.php (scopeBookable(), BOOKING_LEAD_TIME_DAYS)

Tracks multiple flight legs per offer.

Table: offer_flights

FieldTypeDescription
offer_idFKParent offer
leg_indextinyintOrder within trip (0=intl, 1+=domestic)
flight_typestringinternational or domestic
source_typestringcache or manual
dynamic_flight_cache_idFK (nullable)Cached flight reference
flight_booking_idFK (nullable)Manual booking reference
pricedecimalPrice for this leg

Unique constraint: (offer_id, leg_index) - one flight per leg position.

Source: backend/app/Models/OfferFlight.php

Offer
└── OfferFlight[] (hasMany, ordered by leg_index)
├── DynamicFlightCache (when source_type='cache')
└── FlightBooking (when source_type='manual')
// Offer model
$offer->hasLandComponent(); // Has tour linked?
$offer->getRoomTypeLabel(); // "2 Adults" from "2A"
$offer->getReturnDate(); // Departure + trip duration
$offer->getPaxCount(); // Parse room_type for pax count (default: 2)
$offer->isEditable(); // True if draft
$offer->hasMultipleFlightLegs(); // Has 2+ legs?
$offer->getTotalFlightPrice(); // Sum of all leg prices
$offer->calculateFinalPrice(); // Calculates per-pax rounded price
$offer->marketing_price_per_pax; // Clean per-person price for website
// Query scopes
Offer::query()->bookable(); // Active + departure >= today + 5 days
// OfferFlight model
$leg->isInternational(); // flight_type check
$leg->isDomestic(); // flight_type check
$leg->isCachedFlight(); // source_type check
$leg->isManualFlight(); // source_type check
$leg->getFlightSource(); // Returns cache or booking
$leg->getRouteString(); // Route from source