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.
Creation Wizard
Section titled “Creation Wizard”Offers are created through a 3-step wizard in the admin panel.
Step 1: Product & Tour
Section titled “Step 1: Product & Tour”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”)
Step 2: Select Flights
Section titled “Step 2: Select Flights”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
FlightBookingwithsource: manual
Source: backend/app/Enums/FlightBookingSource.php defines api | manual | checkout values.
Multi-Leg Flight Selection
Section titled “Multi-Leg Flight Selection”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_domesticflag
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
Step 3: Review
Section titled “Step 3: Review”Preview offer before creation:
- All selected flight legs with individual prices
- Tour Services breakdown (hotels + included activities with prices)
- Combined total and margin calculation
View Offer Page
Section titled “View Offer Page”The offer detail page shows all components organized in sections.
Offer Details
Section titled “Offer Details”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
Pricing
Section titled “Pricing”Shows the price breakdown and margin calculation:
| Field | Example | Description |
|---|---|---|
| Flight Price | 691.99 | Flight cost for all passengers |
| Land Price | 388.00 | Tour cost for all passengers |
| Total Base Price | 1,079.99 | Combined flight + land |
| Margin | 20% | Markup percentage (inherited from market’s default_margin) |
| Price per Person | 650.00 | Per-pax price (rounded to nearest 10) |
| Final Price | 1,300.00 | Total price (per-pax × pax count) |
The Pricing section also surfaces marketing_price_per_pax
(final_price / pax_count) as a read-only field, and the same value is
shown as a column on the offers list table.
Land Component (Tour)
Section titled “Land Component (Tour)”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
Tour Services
Section titled “Tour Services”Services breakdown showing individual prices for the selected room type. The Total Land Price displays in the market currency. For package services priced in a foreign currency, the preview shows a per-package conversion breakdown (e.g., “Package Japan — JPY 200,000.00 -> EUR 1,090.33”).
| Service Type | Price Calculation |
|---|---|
| Hotel | Per-night x nights at location |
| Activity | Per-person x number of travelers |
| Package | Flat price, converted to market currency individually |
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())
Hotels
Section titled “Hotels”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)
Market Product
Section titled “Market Product”Link to the associated product configuration with market and status.
Flight(s)
Section titled “Flight(s)”Flight details from cache or manual booking. For multi-leg offers, shows each leg:
| Field | Description |
|---|---|
| Flight Type | International or Domestic |
| Route | Airport codes (e.g., MAD-DEL-BKK) |
| Departure Date | Flight departure |
| Price | Cost for this leg |
| CUG Type | Fare category |
| Source | cache or manual |
ProductByMarket View Section
Section titled “ProductByMarket View Section”The ProductByMarket view page surfaces a read-only “Offers” section that lists every offer generated for the product. It is the inverse of the Offer view’s “Market Product” link — operators land on the product, see its current offer inventory, and click through to any specific offer.
| Column | Source field |
|---|---|
| SKU | sku (links to OfferResource view page, opens in a new tab) |
| Status | status (badge, colored via OfferStatus::HasColor) |
| Departure | departure_date |
| Airport | departureAirport.iata_code |
| Price | final_price formatted in currency.code |
| CUG | cug_type |
The section description shows the offer count (e.g. 12 offer(s) generated for this product.). When no offers exist, a placeholder prompts the operator to run the cascade and the Auto-Generate Offers action.
The infolist eager-loads offers.departureAirport and offers.currency once for the whole section so the count, the row entries, and the per-row currency.code lookup all reuse a single load.
Source: backend/app/Filament/Resources/ProductsByMarket/Schemas/ProductByMarketInfolist.php (Section::make('Offers'))
Pricing Formula
Section titled “Pricing Formula”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 = Σ(convert(service_price, service_currency, market_currency)) for each Package Service (if any), OR each Hotel + Included Activity ServiceBase Price = Total Flight Price + Land PriceMargin% = market.default_margin (fallback: 20%)Raw Total = Base Price × (1 + Margin%)Per-Pax Price = roundToMarketingPrice(Raw Total / Pax Count)Final Price = Per-Pax Price × Pax CountMargin Resolution
Section titled “Margin Resolution”When an offer is created without an explicit margin, OfferObserver::resolveDefaultMargin() resolves the default:
- Read
market.default_marginfrom the offer’s ProductByMarket -> Market - If no market is found, fall back to
Offer::DEFAULT_MARGIN(20%)
Setting margin=0 on an offer is a valid override and will NOT be replaced by the market default. Only null (unset) triggers resolution.
Each market can configure its own default_margin (decimal, 0-100%). The migration defaults existing markets to 20%.
Source: backend/app/Observers/OfferObserver.php (resolveDefaultMargin()), backend/app/Models/Market.php (default_margin)
Variable Passenger Count (Checkout)
Section titled “Variable Passenger Count (Checkout)”When a customer selects a non-2A room type during checkout (e.g., 3 passengers), the checkout session recalculates pricing:
- Flight prices scale linearly:
(offer.flight_base_price / 2) x actual_pax_count - Land prices are recalculated:
AutoOfferGeneratorService::calculateLandPrice(tour, actual_room_type, departure_date, offer.currency)with fallback to storedland_base_priceif conversion fails - Per-pax marketing rounding is applied: Same rounding logic as offers, but with actual pax count
- 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 returnnull(unavailable)
The offer itself remains unchanged at 2A pricing. The checkout session is the source of truth for actual passenger count and pricing.
Per-Person Marketing Price
Section titled “Per-Person Marketing Price”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, assuming default 20% margin):
Base Price: €3,957.86 (for 2 pax)With 20% margin: €3,957.86 × 1.20 = €4,749.43 totalPer-pax raw: €4,749.43 / 2 = €2,374.72Per-pax rounded: €2,370 (nearest multiple of 10)Final price: €2,370 × 2 = €4,740Example calculation (3 pax, checkout, assuming default 20% margin):
Base Price: €5,936.79 (flight + land for 3 pax, recalculated)With 20% margin: €5,936.79 × 1.20 = €7,124.15 totalPer-pax raw: €7,124.15 / 3 = €2,374.72Per-pax rounded: €2,370 (nearest multiple of 10)Final price: €2,370 × 3 = €7,110The 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 Components
Section titled “Land Price Components”Land price uses one of two models, determined by the supplier tour’s package services:
Package Services (when packageServices relationship is not empty): Sum of all linked package service prices for the room type and date. Each active package service’s rate is looked up independently and prices are summed. Individual hotel/activity prices are ignored.
Itemized Pricing (when no package services): Sum of individual service prices:
| Component | Calculation | Room Type Lookup |
|---|---|---|
| Hotel Services | rate_price x nights | Selected room type (e.g., 2A) |
| Activity Services | rate_price x travelers | Always per_person |
Note: Only included activities are counted. Upsell activities are optional and not in base price.
Mixed-Currency Conversion
Section titled “Mixed-Currency Conversion”When a tour combines services from suppliers in different currencies (e.g., a Japan package priced in JPY and a Thailand package priced in THB, sold in a EUR market), each service price is converted to the market currency individually before summing. This prevents mixed-currency arithmetic errors where raw amounts in different currencies were added together.
calculateLandPrice()accepts an optional$targetCurrencyparameter- When provided, each service price is converted via
CurrencyExchangeRate::getRateForDate()before accumulating - If any required exchange rate is missing, the method returns
null(caller skips or falls back) - Auto-offer generation, the Create Offer wizard, and checkout all pass the market currency
Checkout fallback: If currency conversion fails at checkout (e.g., missing exchange rate), the system falls back to the stored land_base_price from the offer, which was already converted at creation time.
Source: backend/app/Services/Offers/AutoOfferGeneratorService.php (calculateLandPrice(), convertToTargetCurrency())
Marketing Price Rounding
Section titled “Marketing Price Rounding”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.
Extras Price Rounding
Section titled “Extras Price Rounding”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())
SKU Format
Section titled “SKU Format”Pattern: <ProductByMarket SKU>-<Airport>-<Date>-<Sequence>
Example: ES-173-10-ES1-MAD-260301-01
| Part | Value | Meaning |
|---|---|---|
| ES-173-10-ES1 | ProductByMarket SKU | Spain, Product 173, 10 days, template 1 |
| MAD | Airport IATA | Madrid departure |
| 260301 | YYMMDD | March 1, 2026 |
| 01 | Sequence | First offer for this combination |
Status Lifecycle
Section titled “Status Lifecycle”| Status | Editable | Description |
|---|---|---|
| Draft | Yes (margin only) | Work in progress, margin defaults from market |
| Active | No | Published and locked |
Once active, offers cannot be modified. Create a new offer instead.
When an offer transitions to Active, OfferObserver::updating stamps
offers.final_price_locked_at. From that moment onwards the
flight upgrade pipeline applies
the price floor — the customer-facing final_price never decreases, even
if the bound flight gets cheaper. Drafts are unlocked and recompute freely.
Flight Upgrade Pipeline
Section titled “Flight Upgrade Pipeline”After activation, every live flight search the customer or an admin
triggers flows through OfferFlightUpgradeService. It can swap the bound
flight to a better fare (when FlightRankingPolicy says so) and it always
records a price snapshot when the recomputed numbers change. Both the
customer checkout and the admin “Recalculate offer” button on the offer
view page share the same code path; the only difference is the tag stored
on the binding (source) and snapshot (reason).
See Offer Flight Upgrade Service
for the floor rule, trigger rule, source/reason taxonomy, and a worked
example. See Offers History for the
schema of offer_flight_bindings and offer_price_snapshots.
Bookability
Section titled “Bookability”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)
Data Model
Section titled “Data Model”OfferFlight (Pivot Model)
Section titled “OfferFlight (Pivot Model)”Tracks multiple flight legs per offer.
Table: offer_flights
| Field | Type | Description |
|---|---|---|
| offer_id | FK | Parent offer |
| leg_index | tinyint | Order within trip (0=intl, 1+=domestic) |
| flight_type | string | international or domestic |
| source_type | string | cache or manual |
| dynamic_flight_cache_id | FK (nullable) | Cached flight reference |
| flight_booking_id | FK (nullable) | Manual booking reference |
| price | decimal | Price for this leg |
Unique constraint: (offer_id, leg_index) - one flight per leg position.
Source: backend/app/Models/OfferFlight.php
Relationship Flow
Section titled “Relationship Flow”Offer └── OfferFlight[] (hasMany, ordered by leg_index) ├── DynamicFlightCache (when source_type='cache') └── FlightBooking (when source_type='manual')Key Methods
Section titled “Key Methods”// 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 scopesOffer::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 sourceAuto Offer Generator
Section titled “Auto Offer Generator”AutoOfferGeneratorService creates offers from completed flight cache entries linked to eligible products. Per product, for each active ProductByMarketFlightConfig and each rate period, it pairs an unlinked international cache entry with the cheapest compliant domestic options (when the config has domestic legs) and creates the offer.
At creation time the generator also seeds the offer’s flight upgrade
history — one offer_flight_bindings row per leg with source = 'generator'
and one offer_price_snapshots row with reason = 'generated'. From there
on, every audited price write goes through
OfferFlightUpgradeService.
Offers per departure date
Section titled “Offers per departure date”Default: 1 offer per departure date per (product, airport, rate period).
The ranking heuristic already picks the best fit, so additional rows are
noise unless the operator explicitly asks for variety.
The Auto-Generate Offers admin actions (both the one on the Offers list
page and the one on the ProductByMarket view page) expose a
maxOffersPerDate parameter that accepts values from 1 to 5, letting an
operator opt into carrier diversity (cheapest direct + alternates) for a
single run. The scheduled offers:auto-generate command always runs at the
default of 1.
Constants: AutoOfferGeneratorService::DEFAULT_MAX_OFFERS_PER_DATE = 1.
Source: backend/app/Services/Offers/AutoOfferGeneratorService.php
Triggers
Section titled “Triggers”generateForAllProducts() is invoked from two places, both running the same code path against the same eligibility filter:
- Scheduler —
Schedule::command('offers:auto-generate')->everyFifteenMinutes()->withoutOverlapping()->onOneServer()inroutes/console.php. The default automated trigger. - Header Action — an “Auto-Generate Offers” button on the Offers list page (warning color, bolt icon) for operators who just queued cascade searches and don’t want to wait up to 15 minutes for the next scheduler tick. It runs synchronously inside the request, so the operator stays on the page until generation completes.
The header action shows a one-line stats notification on completion:
{products_processed} products processed · {offers_created} created · {offers_skipped} skipped · {errors} errorsThe notification color reflects the outcome: success when offers were created, warning when any errors occurred, gray otherwise. Exceptions thrown by generateForAllProducts() surface as a danger notification with the error message.
Source: backend/app/Filament/Resources/Offers/Pages/ListOffers.php, backend/routes/console.php, backend/app/Console/Commands/GenerateAutoOffersCommand.php
Arrival-Time Compliance
Section titled “Arrival-Time Compliance”The flights.search.latest_arrival_time setting is enforced at offer creation time, not just in the admin list. Without this gate the setting would only decorate the cache page while non-compliant fares still shipped to customers.
rejectNonCompliantArrivals(EloquentCollection $candidates) drops cache rows whose outbound-leg final arrival falls outside [06:00, cutoff] by calling DynamicFlightCache::violatesLatestArrivalTime($cutoff) on each row. The daytime-window rule is the same one the admin badge uses — see Dynamic Flight Cache — Arrival-Time Compliance Flag. It is a no-op when the setting is empty.
The cutoff is resolved once per service instance and memoized (memoizedArrivalCutoff, guarded by arrivalCutoffResolved), so a full product-generation pass reads Setting once instead of on every candidate loop.
Two call sites apply the filter:
- International legs —
findUnlinkedFlights()eager-loads route + segments, applies the DB-side operating-periods filter, then hands the candidates torejectNonCompliantArrivals()before returning. All compliant candidates remain available for date/CUG matching downstream. - Domestic legs —
findDomesticFlights()fetches the top 10 cheapest candidates for the leg/date, runs them throughrejectNonCompliantArrivals(), and takes the first surviving row. This mirrors the international behaviour: filter in PHP and still end up with the cheapest compliant option.
Domestic Leg Date Derivation
Section titled “Domestic Leg Date Derivation”Each domestic cache row stores departure_date as the actual domestic flight date (what the populator searched), not the international trip start. To pair a domestic cache with the right international flight, findDomesticFlights() receives the $internationalFlight cache row and computes the expected domestic date per leg as:
expectedDate = internationalFlight.getOutboundArrivalTime() + leg.day_offsetA ±1 day window (whereBetween(expectedDate - 1d, expectedDate + 1d)) absorbs the same-day vs next-day ambiguity for overnight international arrivals (e.g. MAD→LIM departing Sep 10 16:25 and arriving Sep 11 14:45). This matches the matching logic in CreateOffer::getDomesticFlightQuery (Filament manual-offer UI) so both paths pick the same cache row for a given intl candidate.
Both paths rely on the cache row’s segments being loaded (->with(['segments'])) — violatesLatestArrivalTime reads them directly.
Related
Section titled “Related”- Offer Flight Upgrade Service - Audited binding swaps and price snapshots
- Offers History (database) - Append-only history tables
- Suppliers - Tour providers and rates
- Products by Market - Product configuration
- Dynamic Flight Cache - Flight data source
- Flight Search UI — Search Filter Settings - Admin cutoff configuration