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:

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% Margin percentage, interpreted per the offer’s margin_basis (new offers: % of the selling price; inherited from market’s default_margin, or custom_quote_margin for manual creations)
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.

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

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:

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

The ProductByMarket view page surfaces the product’s offers via OffersRelationManager, which calls OffersTable::configure() — the same table configuration as the standalone /admin/offers list page, scoped to this product’s offers. Operators land on the product, see its full offer inventory (paginated, filterable, with the same bulk actions), and click any row through to the offer view page. The legacy bespoke RepeatableEntry block on the infolist was removed; the offers list now lives in the relation manager.

Because both surfaces share OffersTable::configure(), the column set is identical. The date columns include both Departure (departure_date) and Arrival (Offer::getArrivalDate() — the outbound flight’s destination-landing day, which is the day after departure on overnight long-haul).

When no offers exist, an empty state prompts the operator to run the cascade and the Auto-Generate Offers action.

Source: backend/app/Filament/Resources/ProductsByMarket/RelationManagers/OffersRelationManager.php, backend/app/Filament/Resources/Offers/Tables/OffersTable.php

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 × campaign_factor, service_currency, market_currency))
for each Package Service (if any), OR each Hotel + Included Activity Service
+ extra-night supplement (package branch only, NOT discounted)
campaign_factor = 1 − effective_last_seats_percent/100 (1.0 when no campaign)
Insurance Price = whole-party cost of the market's included base policy (0 when none)
Base Price = Total Flight Price + Land Price + Insurance Price
Margin% = market.default_margin (fallback: 20%)
Raw Total = Offer::priceFromCost(Base Price, Margin%, margin_basis)
sale basis (new offers): Base Price ÷ (1 − Margin%/100)
cost basis (legacy offers): Base Price × (1 + Margin%/100)
Per-Pax Price = roundToMarketingPrice(Raw Total / Pax Count)
Final Price = Per-Pax Price × Pax Count

Every offer carries a margin_basis (App\Enums\MarginBasis) that says how its margin % is interpreted:

Basis Formula A configured 20% means
sale (default for new offers) price = cost ÷ (1 − margin/100) A real 20% profit on the selling price (cost 5,000 € → price 6,250 €, profit 1,250 €)
cost (legacy) price = cost × (1 + margin/100) A 20% markup on cost = only 16.67% of the selling price (cost 5,000 € → price 6,000 €, profit 1,000 €)

All margin math goes through two central helpers on the Offer model — Offer::priceFromCost(float $cost, float $marginPct, MarginBasis $basis) and its inverse Offer::costFromPrice(float $price, float $marginPct, MarginBasis $basis). Never inline the formula.

Key rules:

  • Existing offers were backfilled to cost when the column was introduced, so nothing repriced at deploy. New offers (auto-generated and manual) default to sale (DB default + Offer::$attributes).
  • The basis is frozen into the checkout session at start() (session key margin_basis, next to margin) and travels into the booking snapshot. Sessions/snapshots written before the field existed resolve to cost via MarginBasis::fromSnapshot() — in-flight checkouts always complete at the price the customer saw.
  • Sale-basis margins must be < 100% (the formula divides by 1 − margin/100); OfferObserver rejects ≥ 100 and the admin forms cap the input at 90.
  • Financial model: COGS aggregation uses a SQL CASE WHEN on offers.margin_basis, so mixed cost/sale fleets aggregate correctly. The average unit-economics model interprets config('financial.margin_percent') per config('financial.margin_basis') (kept at cost until the fleet is migrated).

Migrating legacy offers: offers:migrate-margin-basis

Section titled “Migrating legacy offers: offers:migrate-margin-basis”

Flipping a cost-basis offer to sale at the same configured % raises its customer price (~+4.2% at 20%), so live offers are migrated deliberately, market by market, in coordination with the business:

Terminal window
# Dry-run (default): per-offer old → new price deltas, nothing written
php artisan offers:migrate-margin-basis --market=ES
# Persist; drafts + approved offers only by default
php artisan offers:migrate-margin-basis --market=ES --apply
# Active offers require --status=active plus an interactive confirmation
php artisan offers:migrate-margin-basis --status=active --apply

Filters: --market=, --product=, --offer=, --status=*. Booked offers are gated in two tiers:

  • Collected first payment — an offer with any live booking that has a Succeeded payment (deposit or full) is a committed sale at the price the customer already paid on. It is always skipped, even with --include-booked. (The paid booking itself is frozen via its snapshot and never changes either way; the guard is about not moving the price for the next buyer while a paid customer sits on the old one.)
  • Unpaid bookings — offers whose only live bookings are pre-payment (checkout in progress, pending payment, quotation requested) are excluded by default but migrated with --include-booked. In-flight sessions still complete at the basis frozen into them, so opting these in is safe.

Offers with no live bookings migrate by default. Each migrated offer is repriced through the observer pipeline and gets an offer_price_snapshots row with reason margin_basis_migrated.

Source: backend/app/Enums/MarginBasis.php, backend/app/Models/Offer.php (priceFromCost(), costFromPrice(), marginBasis()), backend/app/Console/Commands/MigrateOfferMarginBasisCommand.php

The market’s included (free-to-customer) base insurance is bundled into the package price as its own component (offers.insurance_base_price), so the customer pays it inside the package instead of Volāre absorbing it — while the amount is never broken out to the client (see Travel Insurance). IncludedInsuranceCostService resolves the per-pax rate from a live Intermundial quote (cached per policy, since the base policy is flat per pax) and falls back to intermundial.included_cost_per_pax_fallback when the quote is unavailable, so offer generation never blocks on the insurance API. Markets without an included policy get a 0 component. Offers that existed before the change were repriced by a one-shot data backfill migration (2026_07_07_144845_backfill_included_insurance_into_existing_offers), which skips offers with committed bookings and snapshots each reprice; offers:recalculate-prices --apply also re-prices the component on demand (the targeted --package-date-fix-only mode never touches it).

Source: backend/app/Services/Insurance/IncludedInsuranceCostService.php, backend/app/Services/Offers/AutoOfferGeneratorService.php (createOffer())

When an offer is created without an explicit margin, OfferObserver::resolveDefaultMargin() resolves the default:

  1. Read market.default_margin from the offer’s ProductByMarket -> Market
  2. 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 configures two margins in Markets → Commercial Settings (both are margin-on-sale for new offers, capped at 90%):

Column Default Used by
default_margin 20% Auto-generated offers (OfferObserver::resolveDefaultMargin())
custom_quote_margin 25% Custom quotations — offers created manually by an agent in the admin wizard (CreateOffer::getSelectedMarketDefaultMargin()), which passes the margin explicitly at creation. Fallback: Offer::DEFAULT_CUSTOM_QUOTE_MARGIN (25%)

Both remain overridable per offer while the offer is draft.

Source: backend/app/Observers/OfferObserver.php (resolveDefaultMargin()), backend/app/Models/Market.php (default_margin, custom_quote_margin), backend/app/Filament/Resources/Offers/Pages/CreateOffer.php

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) x actual_pax_count
  2. Land prices are recalculated: AutoOfferGeneratorService::calculateLandPrice(tour, actual_room_type, departure_date, offer.currency). When it returns null — missing exchange rate, an unsatisfied cost slot, or a missing/zero room-type price — the session throws RoomTypeUnavailableException (422 room_type_unavailable) rather than quoting the stale 2A land price
  3. A Last Seats discount is applied once to the party’s total land, not per room — the campaign seat quota covers the booking, not each room. This step is specific to this non-standard branch; a standard 2A session takes the offer’s stored (already discounted) land_base_price instead
  4. The bundled insurance component scales linearly too: (offer.insurance_base_price / 2) x actual_pax_count (the base policy is flat per pax)
  5. Per-pax marketing rounding is applied: Same rounding logic as offers, but with actual pax count
  6. 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.

Because non-standard pax recalculation reads supplier_service_rate_prices live, a supplier currency change can affect what these checkouts compute until the operator finishes verifying numeric values against the new currency. Standard 2-pax checkouts use the frozen final_price and are unaffected.

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, default 20% margin on sale):

Base Price: €3,957.86 (for 2 pax)
With 20% margin on sale: €3,957.86 ÷ 0.80 = €4,947.33 total
Per-pax raw: €4,947.33 / 2 = €2,473.66
Per-pax rounded: €2,470 (nearest multiple of 10)
Final price: €2,470 × 2 = €4,940

Example calculation (3 pax, checkout, default 20% margin on sale):

Base Price: €5,936.79 (flight + land for 3 pax, recalculated)
With 20% margin on sale: €5,936.79 ÷ 0.80 = €7,420.99 total
Per-pax raw: €7,420.99 / 3 = €2,473.66
Per-pax rounded: €2,470 (nearest multiple of 10)
Final price: €2,470 × 3 = €7,410

Legacy cost-basis offers apply × (1 + margin/100) instead — their stored prices never move until deliberately migrated (see Margin Basis).

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 services.

When the packageServices relationship is not empty, land cost is the sum of the tour’s cost slots — not of its attached packages. A slot is either:

  • a mandatory package — pivot package_service_supplier_tour.alternative_group is NULL, meaning the package is a leg of every trip on this tour (the tour itself, its domestic-flight package, an extension); or
  • a set of alternatives — the packages sharing one non-null alternative_group label are a single slot: versions of the same thing (a package beside its next-season re-issue, one tour split across operating-day variants, a run of season price tiers), of which a departure needs exactly one.

What calculateLandPrice() enforces for a given departure date and room type:

  • Every slot must yield exactly one applicable rate with a strictly positive price. If any slot cannot, the method returns null and no offer is generated for that date.
  • An alternative slot is priced once, never summed. Tier validity windows routinely overlap by a few days at each handover; summing every applicable package charged both tiers on those days. That double-charge is what put mid-December Konnichiwa departures at roughly €21–23k instead of €11–12k.
  • When two members of a slot both apply, the lowest package id wins, so the choice is stable rather than dependent on the order the database returned rows in.
  • Inactive package services are ignored entirely.
  • A missing room-type row, or a stored 0, is an unfilled cell — not a component worth nothing. Checked before FX conversion, so a conversion can never turn a real price into a rejected one (or the reverse). On a single-package tour a 0 would have made the flight the entire price.

Slot resolution lives in its own class, PackageSlotResolvercalculateLandPrice() and the supplier-facing Last Seats land cost both call it, so the price the customer pays and the breakdown the supplier is shown can never disagree about which packages a departure includes. It returns null when any slot has no member serving the date, and an empty list when the tour has no package services.

The classification is stored business data and deliberately not inferred at runtime. An inference reads the very rates whose misfiling it has to guard against: a mandatory extension whose rates were never entered — or entered on the wrong season — would classify itself as an alternative and drop silently out of the price. So an unclassified package is mandatory, and unclassified data fails closed: the date is refused rather than sold below cost. Generation logs the refusal generically (land_price_unavailable); the precise cause — which package, and the command that classifies it — comes from the activation guard. Seed the column with offers:classify-package-alternatives.

Individual hotel/activity prices are ignored on package tours.

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 Selected room type, falling back to per_person

A service with no rate covering the date is skipped, as before. A service that has an applicable rate but no positive price for the room type makes the whole land price null — the same positive-price rule as the package branch. The old if ($price) tested the row’s existence rather than its value, so a stored 0 counted as a free component and every unpriced service silently left the sum.

This branch is effectively dormant (every product with an open search window prices through packages), so it was aligned with the package branch rather than given its own component-vs-alternative policy.

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

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 $targetCurrency parameter
  • 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 the date)
  • Auto-offer generation, the Create Offer wizard, and checkout all pass the market currency

Checkout: a non-standard-pax checkout recalculates land live. When calculateLandPrice() returns null — missing exchange rate, unsatisfied cost slot, missing or zero room-type price — the session does not fall back to the stored land_base_price: it throws RoomTypeUnavailableException, returned as 422 room_type_unavailable. A standard 2-pax session quotes the offer’s frozen final_price instead of re-deriving land — but land is still recomputed on every start for the below-cost gate, which can refuse a 2-pax checkout with a 409.

calculateLandPrice() takes an optional final ?ProductByMarket $productByMarket parameter that opts the date into a Last Seats campaign:

calculateLandPrice($tour, $roomType, $date, $targetCurrency, $packageDate, $productByMarket)

It is the one input the calculation cannot derive from the tour — a tour is shared by every market, a campaign belongs to one.

  • In the package branch the percentage multiplies each supplier service price in the supplier’s own currency, before FX conversion — converting first gives the same euros but leaves nothing to show the supplier in their currency. The itemized branch applies the factor after conversion, at the calculatePrice() call site ($service->calculatePrice($unitPrice * $campaignFactor, …)) — nothing inside SupplierService::calculatePrice() knows about campaigns.
  • It is pro-rated to the seat quota: a party larger than the remaining campaign seats is charged the campaign price for the seats it takes and the normal price for the rest (discount_percent × min(travellers, seats_left) / travellers).
  • The extra-night supplement is excluded — it is added to the package total after the campaign factor and is quoted case by case.
  • A campaign never makes the method return null. null means “this date cannot be sold”; a spent quota only means the discount is over, and the factor falls back to 1.0.

Generation, offers:recalculate-prices, the activation guard and the Create Offer wizard pass the product. Checkout and the trip configurator deliberately do not: they price each room without it and then apply the discount once to the party’s total, because the quota belongs to the booking rather than to a room. That path is the non-standard-pax one — a standard 2A checkout prices nothing and applies nothing, taking the offer’s stored (already discounted) values. The diagnostic callers omit it too — they measure whether the supplier data can price at all, not what Volāre currently charges.

Because every public surface reads the persisted offer prices, a campaign only reaches customers once those rows are rewritten — see Last Seats → Persisted prices are the read path.

Source: backend/app/Services/Offers/AutoOfferGeneratorService.php (calculateLandPrice(), campaignDiscountFactor(), convertToTargetCurrency()), backend/app/Services/Offers/PackageSlotResolver.php

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.

Every extra follows one rounding rule: whole currency units per person, rounded to the nearest unit. Per-person prices use Offer::roundToWholeUnit() (nearest €1 / $1 / £1); per-booking lines (hotel room configurations, per-trip transfers, flight-supplement totals) are snapped with Offer::roundLineToWholeUnitPerPax() — the line is divided by the party size, rounded to the nearest whole unit, and multiplied back. Because every line is a whole number of units per person, any per-person figure × pax equals its line total exactly and the checkout header (grand total ÷ pax) never shows a cent gap. Nearest (not ceil) keeps the charged price as close as possible to cost + margin; the realized margin varies by at most half a unit per person in either direction. The €10 marketing rounding is reserved for the offer’s base price at creation.

Extra Margin applied? Formula
Hotel upgrade Yes — fixed 20% roundLineToWholeUnitPerPax(Offer::priceFromCost(config cost, EXTRAS_MARGIN, offer basis), pax) (per booking line)
Activity upgrade Yes — fixed 20% roundToWholeUnit(Offer::priceFromCost(cost, EXTRAS_MARGIN, offer basis)) (per person)
Transfer upgrade Yes — fixed 20% Per person: roundToWholeUnit(...) × pax. Per trip: roundLineToWholeUnitPerPax(..., pax)
Flight upgrade (business/economy) Yes — offer margin roundLineToWholeUnitPerPax(Offer::priceFromCost(fare delta, offer margin, offer basis), pax) (line; per person = line ÷ pax, exact)
Insurance No (included) / Yes (paid upgrade) Included base: Intermundial retail as-is. Paid upgrades: session margin at the session’s margin basis (CheckoutInsuranceService::clientPriceFor()). Not pax-snapped — the premium is a flat party price

Hotel, activity, and transfer upgrades carry a fixed 20% margin (Offer::EXTRAS_MARGIN), independent of the offer’s own configurable margin — changing offer or market margins never moves extras pricing. Flight cabin upgrades use the offer’s own margin (they are part of the core flight price). The fixed/offer % is applied on the offer’s margin_basis so a single booking never mixes semantics: sale-basis offers price extras at cost ÷ 0.80 (a real ~20% of that extra’s sale), legacy cost-basis offers keep cost × 1.20. The PDP preview “from” prices (ProductByMarketResource::getHotelLowestPrice() / getActivityLowestPrice()) use the sale basis (the new-offer default).

The margin is supplied via a fluent withMargin(float $margin, MarginBasis $basis): static setter on HotelPriceCalculatorService, ActivityPriceCalculatorService, and TransferPriceCalculatorService, called once per flow with Offer::EXTRAS_MARGIN and the offer’s basis (the hotel calculator additionally takes the pax count, since its lines are per booking). The calculators default to a margin of 0.0 (raw cost) on purpose: a code path that forgets to call withMargin() produces a visibly-wrong cost price that surfaces in QA, rather than a silently-wrong margin. The transfer calculator exposes the raw supplier cost separately (getTransferCost() / getTransferCostTotal()) so the booking’s P&L records the real cost, not the margined price.

Source: backend/app/Models/Offer.php (roundToWholeUnit(), roundLineToWholeUnitPerPax(), roundToMarketingPrice(), EXTRAS_MARGIN); backend/app/Services/Checkout/HotelPriceCalculatorService.php, ActivityPriceCalculatorService.php, TransferPriceCalculatorService.php

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 Value Editable Description
Draft draft Yes Work in progress, margin defaults from market
Approved by supplier approved_by_supplier Yes Auto-set when the supplier signs the contract that references the offer’s tour rate. Still not published — Volāre must explicitly activate it.
Active active Yes Published on the web

Draft → ApprovedBySupplier happens automatically: SupplierContract::booted() listens for the contract’s status changing to Signed and bulk-updates every Offer whose supplier_tour_rate_id is in the contract’s contract_services. Active offers are not touched. The same hook dispatches RecalculateSupplierOfferPricesJob when the signed document is an addendum — see after a signed addendum.

Offers are editable in any status — an admin can change an active offer, and it reprices in place through the normal OfferObserver pipeline. Two scheduled processes rely on this: price recalculation and release-window auto-draft.

Activation Guard (no below-cost publishing)

Section titled “Activation Guard (no below-cost publishing)”

The automatic path never publishes an offer it cannot price — calculateLandPrice() returns null and the generator skips the date. The manual doors had no such check and flipped the status regardless, so an offer generated before a pricing rule existed, or one whose package rates changed since, could go live below cost.

OfferActivationGuard is now the single decision for both manual doors: the Activate selected bulk action on the Offers table, and the status toggle on the offer edit form. An offer with a land component may become Active only when all four hold:

  1. Every rate behind it is actually agreed and on sale (see below).
  2. Every cost slot prices for its departure date and room type.
  3. It has a selling price at all (final_price > 0).
  4. flight_base_price + recomputed land + insurance_base_price does not exceed final_price (beyond a one-cent rounding epsilon).

Refusals name the actual cause rather than a generic failure, because “could not activate” without a reason sends the operator back to engineering: a closure (with the rate id and blacked-out range), a missing room-type price on a named rate, a price of 0, a component whose rates ran out (naming the package and offers:classify-package-alternatives), an alternative group where no member covers the date, or a missing exchange rate — that last one flagged explicitly as not a product-data problem. The guard refuses rather than silently repricing: final_price is what a customer sees, and moving it as a side effect of pressing “Activate” would hide a price change inside a status change. Recalculate selected sits in the same menu.

Boundary of the guard. The decision is centralised; the write is not. ActivateOffers (bulk path) judges and writes each offer inside its own transaction under lockForUpdate, so one refusal never discards the batch and a status cannot move between check and update. The edit form enforces the same guard as a Filament validation rule on the status field, keeping the operator on the page with the reason attached. Direct Eloquent writes, factories and backfills are deliberately outside the guard — status is written by seeders and backfills across the thousands of already-Active offers, several of which no longer satisfy it, so throwing at model level is its own change with its own blast radius. Only the transition into Active is gated: offers already Active stay fully editable, so routine margin edits on live offers are not stranded behind a data problem the form cannot fix.

Unagreed rates are refused before the arithmetic. Publishing a rate the supplier has not agreed — or has withdrawn — is not a pricing error, so the guard asks SupplierContractRateResolver::unsellableRates() before any pricing (after only its two preconditions: a linked supplier tour and a resolvable currency), and refuses on either of two cases: a signed addendum withdrew the rate from a date on or before the departure, or an addendum that introduces or changes it is still awaiting signature (and has no authorisation to sell unsigned). The message names the addendum, because that is what the operator has to go and chase. Withdrawal is not liftable; the unsigned case is, by that authorisation.

The check follows the money: the offer’s own supplier_tour_rate_id plus the package rates its land price actually pays for — one rate per cost slot, chosen exactly as calculateLandPrice() chooses them, so a departure is never refused over a rate nobody is being charged for. The itemized hotel/activity branch is not walked; those offers keep the previous behaviour.

Unlike the below-cost test, this one is also enforced at sale time: saleShortfall() — deliberately narrow elsewhere, since an unverifiable cost must not take offers off sale — refuses an unagreed rate too, because that is a fact about the agreement rather than an arithmetic result.

A live offer can still go stale after activation, so the same guard is asked once more at checkout — see Checkout below-cost gate.

Source: backend/app/Services/Offers/OfferActivationGuard.php, backend/app/Services/Offers/ActivateOffers.php, backend/app/Services/SupplierContractRateResolver.php, backend/app/Filament/Resources/Offers/Tables/OffersTable.php, backend/app/Filament/Resources/Offers/Schemas/OfferForm.php

“Stop sale” is not a stored OfferStatus value — it’s a derived display state computed at read time. An offer is considered stopped when its tour has an active stop sale window covering the offer’s arrival date in destination (Offer::getArrivalDate(), last segment of leg_sequence=1 from the bound flight cache, pinned to the cache’s primary outbound itinerary via getPrimaryOutboundItineraryIndex()). The same getArrivalDate() also backs the offers-table “Arrival” column. When the window expires or is removed, the offer reverts to whatever its stored status implies — no DB write required.

Method Purpose
Offer::isStopped(): bool True when the tour has an active stop sale covering the arrival date
Offer::getDisplayStatusLabel(): string Returns “Stop sale” when stopped, “Active · release window” for a kept-active booked offer inside the release window, otherwise the stored status label
Offer::getDisplayStatusColor(): string Returns danger when stopped, warning for the kept-active release-window case, otherwise the stored status color

Source: backend/app/Enums/OfferStatus.php, backend/app/Models/Offer.php (isStopped(), getDisplayStatusLabel(), getDisplayStatusColor(), getArrivalDate()), backend/app/Models/SupplierContract.php (booted())

The bookability release-window filter hides offers from the web the moment their departure enters the supplier release-days window, but the stored status stayed Active in the admin — showing as sellable what could no longer be booked. The scheduled offers:draft-released command (daily) materialises that filter into a real status change:

  • Active offers inside the window with no committed booking move to Draft. The release condition is monotonic (departure only gets closer), so the transition is one-directional — a drafted offer never flips back on its own.
  • Offers with a committed booking (Booking::scopeCommitted()) stay Active, and the offers table badges them “Active · release window” (warning color) through the same derived-state mechanism as stop sales.
Piece Purpose
Offer::scopeInsideReleaseWindow() Positive form of the bookable release check (same driver-aware SQL helper)
Offer::isKeptActiveInReleaseWindow(): bool Active + inside window + committed booking — drives the badge

The command moves each draftable offer with a plain $offer->update(['status' => OfferStatus::Draft]) — offers are editable in any status, so the withdrawal reprices/persists in place through the normal observer pipeline.

--dry-run previews the affected offer IDs without persisting. Each effective run logs the drafted and kept IDs. No separate backfill is needed: the first scheduled run sweeps every offer already inside the window.

Auto-generate interaction: offers:auto-generate may still create an offer for a departure already inside the window — the offers unique constraint only blocks exact duplicates (it includes base_price). The next daily offers:draft-released run sweeps it; filtering these departures out at generation time is a possible follow-up.

Source: backend/app/Console/Commands/DraftReleasedOffersCommand.php, backend/app/Models/Offer.php (scopeInsideReleaseWindow(), isKeptActiveInReleaseWindow()), backend/routes/console.php

Offers freeze their land + flight price at generation time. Nothing recomputed them afterwards, so when a supplier’s currency or the FX rate changed later, old offers stayed frozen at the wrong number. Two failure modes drove this:

  • Currency relabel (~15% underpriced): a supplier’s prices were entered as USD but actually meant EUR, so generation converted them down (×~0.85). When the supplier currency was later corrected to EUR, the SupplierObserver cascade only RELABELS, never converts — so newly generated offers became correct while old ones stayed frozen too cheap.
  • FX staleness (~2–7%): genuinely foreign-currency suppliers carry an old exchange rate on old offers.

The offers:recalculate-prices artisan command re-runs the existing generation pricing logic (no new pricing math) and writes the result back. It recomputes land via AutoOfferGeneratorService::calculateLandPrice($tour, $roomType, $arrivalDate, $currency, $offer->departure_date, $offer->productByMarket) — per-component currency conversion at the latest FX rate (only future-dated offers are touched, so “latest rate” is correct). The target currency is the offer’s market currency, resolved by resolveTargetCurrency($offer) from productByMarket.market.default_currency_code with an EUR fallback — not EUR unconditionally. The fifth argument pins package-service availability and FX to the departure date while the itemized branch keeps arrival, so the recalculated price matches the breakdown and checkout. The sixth carries the market product, so a re-price never strips a running Last Seats discount — nor keeps one that has ended. Flights are left untouched (already EUR). Setting land_base_price and saving lets OfferObserver::saving() recompute base_price, marketing_price_per_pax, and final_price via the offer’s own margin and the existing roundToMarketingPrice().

For currency-relabel remediation, run this command after completing the supplier currency change verification.

Flag Default Effect
--apply off (dry run) Persist changes. Omit for a preview-only run.
--status=* draft + approved_by_supplier Statuses to include: draft, approved_by_supplier, active. active is opt-in.
--product= all Restrict to a single ProductByMarket ID.
--supplier= all Restrict to offers whose tour belongs to this Supplier ID.
--offer= all Restrict to a single Offer ID.
--departure-date= all Restrict to offers departing on this date (Y-m-d). Combined with --product, this is exactly the set of offers one Last Seats campaign departure covers, across every departure airport — which is how SyncLastSeatsPricesJob calls the command.
--force off Skip the interactive active-offer confirmation. For unattended runs — there is no terminal to answer the prompt.
--package-date-fix-only off Only re-persist package offers whose land moves because package services resolve against departure instead of arrival. Bypasses the prompt too, since it corrects genuinely undercharged offers.

Without --apply the command prints an old → new table (land and final price per changed offer) plus an outcome tally, and writes nothing. Re-run with --apply to persist. The recalc is idempotent — re-running an applied scope reports every offer as “unchanged”.

The outcome tally buckets each offer:

Outcome Meaning
Recalculated Land price changed and was (or would be) written
Unchanged New land price matches the stored one (within €0.01)
Skipped — committed booking Offer has a committed booking (see guards)
Skipped — no land component Offer has no land component / tour
Skipped — land unavailable New land price was null or ≤ 0 (never overwrites with 0)
Errors Recompute threw; reported and counted, command exits non-zero
  • Committed bookings: offers with a committed booking are skipped. “Committed” is the new Booking::scopeCommitted() — any booking not in Draft, Checkout, Cancelled, or Expired. This protects the price a customer already agreed to.
  • No land component: offers without a land component (or no linked tour) are skipped.
  • Null / zero land: if recompute returns null or ≤ 0 (missing rate or FX), the offer is skipped — the command never overwrites a real price with 0.

Offers are editable in any status, so this command reprices active offers in place: persist() sets land_base_price / insurance_base_price / recalculated_at and calls $offer->save() directly, letting OfferObserver recompute the derived prices.

Applying to active offers (--status=active --apply) requires an interactive confirmation prompt showing how many active offers are in scope, unless --force is passed (or the targeted --package-date-fix-only mode is used, which only corrects genuinely undercharged offers). offers.final_price_locked_at is deliberately left untouched — recalculation is not a status transition.

Every applied change writes an OfferPriceSnapshot with reason = 'recalculated' and stamps offers.recalculated_at. The original generated snapshot is retained, so the change stays auditable and reversible. See Offers History for the snapshot schema.

recalculated_at is an audit stamp for intervention — this command, the bulk “Recalculate selected” action and two data-repair migrations — not a price-freshness signal. The Arkana offers table’s “Flight price age” column reads dynamic_flight_caches.searched_at over the offer’s current bindings instead (see Offers History).

This command does not fix overlapping rate periods.

Signing an addendum renegotiates rates that are already in the catalogue, so every published offer quoting them is selling on a margin computed against the old ones. SupplierContract::booted() therefore dispatches RecalculateSupplierOfferPricesJob (single attempt, 600 s timeout) when an addendum’s status changes to Signed. The job runs this same command — --apply --force --supplier={id} over draft, approved_by_supplier and active — rather than repeating its arithmetic, so there is one re-pricing mechanism in the codebase and the queued run behaves exactly like an operator’s. --force is what makes it possible unattended: there is no terminal to answer the active-offer prompt, and the signature is the confirmation. The exit code and command output are logged.

The command’s committed-booking guard is what enforces the rule agreed for addendums: a confirmed booking keeps the cost it was closed with, so a renegotiated rate never moves the price of a trip already sold.

Source: backend/app/Console/Commands/RecalculateOfferPricesCommand.php, backend/app/Jobs/RecalculateSupplierOfferPricesJob.php, backend/app/Observers/OfferObserver.php, backend/app/Models/Booking.php (scopeCommitted())

offers:classify-package-alternatives seeds package_service_supplier_tour.alternative_group so pricing knows which of a tour’s packages are mandatory cost slots and which are alternatives of one another.

Terminal window
# Dry run (default): print the proposed groups, write nothing
./vendor/bin/sail artisan offers:classify-package-alternatives
# Persist the proposals
./vendor/bin/sail artisan offers:classify-package-alternatives --apply
# Restrict to one SupplierTour (--tour takes a SupplierTour ID, not a product ID)
./vendor/bin/sail artisan offers:classify-package-alternatives --tour=23

The proposal comes from rate-overlap analysis (PackageServiceOverlap): two packages can only be components of one trip if some single departure activates both — inside both validity windows and on a weekday both rates serve. Packages of one tour whose rates can never serve the same day are proposed as alternatives; everything else stays mandatory. Blackouts are ignored by the analysis, since a closure removes dates from a package that otherwise applies and says nothing about whether two packages belong to the same trip.

Because the inference reads the same rates whose misfiling the pricing rule guards against, it proposes rather than decides, and two cases are flagged for a person instead of guessed:

  • a package with no rates at all (unfinished data entry, not an alternative) — left mandatory;
  • a tour where “never co-applies” is not transitive (A avoids B and B avoids C, yet A meets C), which no consistent grouping can represent.

Re-running is safe: a tour that already has any classified package is skipped, so a manual correction is never overwritten. Group labels are generated as alt-<tourId>-<n>.

Source: backend/app/Console/Commands/ClassifyPackageAlternativesCommand.php, backend/app/Services/Offers/PackageServiceOverlap.php, backend/.ai/rules/offers.md

The pivot column ships mandatory-by-default, so between the migration and the classification, products holding alternatives stop generating new offers (already-active offers are untouched). That is why step 2 follows the migration immediately:

  1. migrate — adds alternative_group (all NULL = all mandatory).
  2. offers:classify-package-alternatives --apply.
  3. Hand-classify what the command flags — the tier sets on tours #23 and #86 (Konnichiwa) are one group each.
  4. Recalculate the offers that carried a double-charged land price (offers:recalculate-prices --offer=… --apply).
  5. Re-run Prepare Flight Searches on the affected product (#54 for the Konnichiwa rollout) so the blackout-filtered cache window is rebuilt.

The offers table shows totals, not components. A Land column (land_base_price, visible by default, immediately before Base) puts the largest component on screen; the Export to Excel header action writes the whole cost breakdown to a spreadsheet, which is what a commercial conversation with a distributor turns on and what was otherwise reachable only from the database.

What it exports: the table’s current query. Filament hands the exporter the filtered and sorted list, so an operator narrows the screen by product, market, departure airport and departure-date range and downloads exactly those rows — not the whole catalogue. The Airport filter is multi-select, so MAD + BCN come back as one file instead of two exports stitched together by hand. Export selected in the toolbar exports the checked rows instead. Both actions offer CSV or XLSX; XLSX needs no extra dependency, since openspout/openspout is a hard requirement of filament/actions.

Columns: ID, SKU, Status, Product, Market, Departure airport, Departure date, Return date, Room type, Pax, Currency, Land price, Flight price, Insurance price, Land price per pax, Flight price per pax, Insurance price per pax, Base price, Margin %, Final price, Price per pax, CUG, and Created at (the last one off by default). Every money column is written as a raw number, not a formatted currency string, so the sheet stays summable — the currency travels in its own column. Pax is not stored: it is derived per row from the room type via Offer::getPaxCount().

Per-pax money columns. Offers are generated at room type 2A, so the stored land_base_price / flight_base_price / insurance_base_price are whole-party figures — the wrong unit for comparing one product against another. Land price per pax, Flight price per pax and Insurance price per pax divide each by the same Offer::getPaxCount() the Pax column reports, rounded to the currency’s two decimals (1,762.55 over 2 is 881.275, and the sheet should not carry a third decimal nobody asked for). The existing total columns are untouched beside them. A null component exports as an empty cell, not 0.00offers.land_base_price and offers.flight_base_price are nullable, and “the land is free” is a different claim from “there is no land component”. (insurance_base_price is NOT NULL DEFAULT 0, so it never takes that path.)

Return date reads the flight, not the calendar. The column is Offer::getTravelDates()[1] — the day the traveller lands back home on the bound international fare — and deliberately not Offer::getReturnDate(), which is departure_date + trip duration - 1. The two disagree: a tour’s duration counts days at destination, so an overnight outbound and a return leg that lands the next day both fall outside it, and on the long-haul products this export exists for the real homecoming is two days past the arithmetic. Offer 23760 (13-day product departing 2026-11-05) leaves home on the 5th, lands at destination on the 6th, leaves destination on the 18th at 22:15 and lands home on the 19th at 16:55 — the calendar formula says the 17th, a day the customer is still abroad. getTravelDates() is also what the booking emails use, so the sheet now agrees with what the customer was told. It falls back to the duration-based date whenever the bound fare cannot supply a return arrival — no international flight bound at all (land-only products, pre-flight quotations), a manually booked leg with no cache row, or a one-way fare with no leg-2 segments — and the two halves fall back independently, so a row can take its departure from the flight and its return from the arithmetic.

It runs on the queue, not inline. The catalogue is roughly 12k active offers, well past what a web request can serialise, so the export is dispatched as a job batch on the default queue and the finished file arrives as a database notification carrying a download link (the panel polls notifications every 30s). A queue worker must be running or the file never appears — the action confirms the export has started either way, so a stopped worker looks identical to a slow one. Only the user who started an export can download its file.

modifyQuery() eager-loads productByMarket.productTemplate, productByMarket.market, departureAirport, currency, the international offerFlights and their flightCache.segments: the export chunks over the whole filtered set, so a lazy relation here is a query per row rather than per page.

The segments are what the Return date column reads, and they only stay cheap because DynamicFlightCache resolves its primary-itinerary times from the loaded segments collection when one is present (see Dynamic Flight Cache — Outbound Flight Timing). Eager-loading alone was not enough while those helpers always re-queried. A regression test counts the queries touching dynamic_flight_cache_segments during an export and fails if the count tracks the row count.

Supplier managers: the export inherits the resource’s supplier scope, so a supplier manager only ever exports offers on tours their supplier is attached to, and it applies the same field redaction as the offer preview described below: every money column is dropped from their file — the three stored components (land_base_price, flight_base_price, insurance_base_price), their three per-pax counterparts, and base_price, margin, final_price, marketing_price_per_pax — and the Land column is hidden from their table. The land figure is withheld rather than recomputed: the column carries the undivided cost, and on a multi-supplier tour that is another DMC’s share.

Source: backend/app/Filament/Exports/OfferExporter.php, backend/app/Filament/Resources/Offers/Tables/OffersTable.php. The exports table is created by backend/database/migrations/2026_09_10_093458_create_exports_table.php.

When a supplier-manager opens an offer, the offer scope filters to offers whose tour the supplier is attached to (supplierTourRate.tour.suppliers), and pricing fields are replaced with a “Not visible” placeholder in the offer preview. Hidden fields:

  • flight_base_price, base_price, margin, final_price, marketing_price_per_pax
  • Total flight price, per-leg price, flight binding price
  • The price history section

This keeps the supplier’s view focused on operational/contract data while pricing remains internal to Volāre. The one deliberate exception is the Land cost breakdown on Trips to Deliver, which shows the supplier its own cost in its own currency — never the customer’s price.

Source: backend/app/Filament/Resources/Offers/OfferResource.php (getEloquentQuery, getRecordRouteBindingEloquentQuery), backend/app/Filament/Resources/Offers/Schemas/OfferPreviewSchema.php

When an offer transitions to Active, OfferObserver::updating stamps offers.final_price_locked_at. That timestamp is the activation gate for the flight upgrade pipeline: the service acts only on activated offers (drafts are owned by the generator). It no longer gates a price floor — an activated offer’s final_price tracks the current flight cost in both directions.

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. The recompute tracks flight cost in both directions — a cheaper flight lowers final_price, a pricier flight raises it. 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 pricing behaviour, trigger rule, source/reason taxonomy, and a worked example. See Offers History for the schema of offer_flight_bindings and offer_price_snapshots.

An Active offer is not necessarily bookable by customers. The bookable() query scope filters to offers that are Active, have a departure date at least 5 days in the future, are not covered by an active stop sale on their tour, AND do not depart inside their supplier release window.

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. The stop-sale filter hides offers whose tour the supplier has paused. The release-window filter respects each DMC’s contractual booking cutoff (supplier_service_rates.release_days) — the number of days before departure the DMC needs to secure land services, as signed in the service agreement — so we don’t sell an offer we can’t fulfil.

Constant: Offer::BOOKING_LEAD_TIME_DAYS = 5

Scope: Offer::query()->bookable() applies:

  • status = Active
  • departure_date >= today + 5 days
  • NOT EXISTS a stop-sale row on the offer’s tour whose [start_date, end_date] window covers offers.departure_date
  • NOT EXISTS a covering package-service rate that is violated — a rate whose [start_date, end_date] window covers offers.departure_date and whose departure_date < today + release_days (strict <, so a departure exactly release_days away stays bookable)

Release window details: A rate is “covering” when its window contains the offer’s departure date (mirrors SupplierService::getRateForDate()). Because the filter is phrased as “exists a violating rate”, when a tour has multiple package services or rate periods with different release_days, the strictest applicable one wins automatically — no aggregate needed. Rates with a NULL release_days, or offers with no covering package rate, are unaffected and keep the 5-day lead-time floor. today is injected from PHP (so Carbon::setTestNow is honoured), and the date+integer arithmetic uses a small driver-aware SQL helper (releaseWindowViolationSql(): PostgreSQL in prod, SQLite in tests).

Departure vs arrival trade-off: Suppliers care about the arrival date in destination, but stop sales are typically multi-day windows that span both departure and arrival, so a departure-overlap test is a portable, sufficient SQL filter for the web/API layer. Precise arrival-date matching is used in the admin UI (offer infolist badge, stop-sale form preview) via Offer::getArrivalDate().

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(), releaseWindowViolationSql(), BOOKING_LEAD_TIME_DAYS)

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

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 (duration-based estimate)
$offer->getTravelDates(); // [departure, return] from bound international flight (falls back to getReturnDate)
$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 + not stop-saled + not in release window
// 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

getReturnDate() is the duration-based estimate (departure + tripDuration − 1) used by checkout flight/hotel/activity/transfer search and the checkout-continuation email. getTravelDates() is flight-aware: it derives [departure, return] from the bound international flight (offer_flights.flight_type = 'international') — outbound departure day and return arrival-home day — and is used for the customer-facing booking emails so long-haul return legs spanning an extra travel day show the day the traveler actually lands (ref #2081). It falls back to $departure_date / getReturnDate() when no international flight is bound (land-only products / pre-flight quotations).

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.

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

The generator is invoked from two places, both running the same AutoOfferGeneratorService code path:

  • SchedulerSchedule::command('offers:auto-generate')->everyFifteenMinutes()->withoutOverlapping()->onOneServer() in routes/console.php. The default automated trigger; runs generateForAllProducts() synchronously inside the scheduler container.
  • Admin actions — an “Auto-Generate Offers” button (warning color, bolt icon) on the Offers list page (optional product select, empty = all eligible products) and in the Actions menu of the ProductByMarket view page (that product only). Both accept the per-date cap (1–5) and dispatch GenerateAutoOffersJob on the offer-generation queue instead of running inline: a large product (five airports, a year of cache) takes minutes, far past the 60 s web ceiling, and used to surface as a bare “Error while loading page” (nginx 504) with nothing in the logs.

Clicking Generate shows an info toast (“Offer generation queued”) immediately. The job carries the product id (nullable), the per-date cap and the acting operator’s user id, takes the shared auto-offers lock (see Queue System), runs the generator, and reports back to the operator’s notification bell as a Filament database notification built by AutoOfferGenerationSummary:

[{products_processed} products processed · ]{offers_created} created · {offers_skipped} skipped[ · {errors} errors]
• {skip reason label} × {count} (top 3, single-product runs only)
• +{n} more

The products clause appears only for all-products runs, the errors clause only when non-zero. Title is “Auto-offer generation completed” when anything was created, “No new offers created” otherwise; color is warning on errors, success when offers were created, gray otherwise. A “View offers” action links to the product page (single-product run) or the Offers list (all products). If the product was deleted meanwhile the operator gets a warning “Auto-offer generation skipped”; if the job fails for good (all retries exhausted) a danger “Auto-offer generation failed” names the scope and error — or, when every attempt lost the lock to another run, says so and asks the operator to re-run later. Operators who are not logged in (scheduler/CLI dispatch) receive nothing.

Source: backend/app/Filament/Resources/Offers/Pages/ListOffers.php, backend/app/Filament/Resources/ProductsByMarket/Pages/ViewProductByMarket.php, backend/app/Jobs/GenerateAutoOffersJob.php, backend/app/Services/Offers/AutoOfferGenerationSummary.php, backend/routes/console.php, backend/app/Console/Commands/GenerateAutoOffersCommand.php

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 legsfindUnlinkedFlights() eager-loads route + segments, applies the DB-side operating-periods filter, then hands the candidates to rejectNonCompliantArrivals() before returning. All compliant candidates remain available for date/CUG matching downstream.
  • Domestic legsfindDomesticFlights() fetches the top 10 cheapest candidates for the leg/date that already match the leg’s configured departure window (see Departure Window Filtering), runs them through rejectNonCompliantArrivals(), and takes the first surviving row. This mirrors the international behaviour: filter in PHP and still end up with the cheapest compliant option.

Each domestic cache row stores departure_date as the actual domestic flight date (what the populator searched), not the international trip start. A domestic leg’s date is fixed by one rule, implemented once in DomesticLegSchedule (backend/app/Services/Flights/Domain/DomesticLegSchedule.php) and used by every path that binds, sells or issues a domestic leg:

expectedDate = day the traveller lands at the destination on the international leg + leg.day_offset

day_offset encodes the tour’s nights per stop, so the match is exact-date — never a neighbour day. A domestic flight one day off silently redistributes nights (two instead of three at a stop) while the hotels stay on the configured dates: that is what reached the clients of bookings 5223 (“Moái”, August 2026) and 6128 (“Nilo”, September 2026). The ±1 day matching window that let a cheaper neighbour-day fare win was removed in #2250 (CU-869eebw18); when the exact day has no compliant fare the leg is left unmatched and the offer is skipped with missing_domestic_leg.

The landing day is read from the last outbound segment of the bound itinerary (DomesticLegSchedule::arrivalOfCache(), falling back to the row’s arrival_date / departure_date), so an overnight international shifts every domestic leg by one day.

Both paths rely on the cache row’s segments being loaded (->with(['segments'])) — violatesLatestArrivalTime reads them directly.

Same-day backstop. Domestic legs must depart and arrive on the same local calendar date (a hardcoded invariant). Overnight domestic fares are already filtered at search time — the populator never caches them — so they normally don’t exist. As defense-in-depth, findDomesticFlights() still rejects any candidate where DynamicFlightCache::arrivesNextDay() is true (e.g. a row cached before the search-time filter shipped, or pending re-search) before binding. The reject runs inside each baggage tier so the bag preference still prefers a same-day fare. See Dynamic Flight Cache — Same-Day Domestic Filter.

Where the rule is enforced after generation

Section titled “Where the rule is enforced after generation”

Generation is only the first place a domestic date is chosen. An offer’s international leg can be rebound later (checkout live search, admin “Recalculate”), a customer can pick an international fare landing on another day, and a product’s legs can be edited — each of these re-created drift after the generator itself had been fixed (417 of the 665 drifted active legs found on 2026-09-12 came from international rebinds). The rule is therefore enforced at every point where a domestic date can change, all through DomesticLegDateResolver (backend/app/Services/Offers/DomesticLegDateResolver.php):

Where What happens
AutoOfferGeneratorService::findDomesticFlights() Exact-date match on the international landing day (this section).
OfferFlightUpgradeService::upgradeIfBetter() on leg 0 DomesticLegRebindPlanner re-derives every domestic leg for the candidate’s landing day and rebinds them in the same transaction (source = intl_rebind_cascade). If a leg has no compliant fare cached on its new date, the international rebind is refused and the missing (route, date) is queued as a pending cache entry so a later attempt can succeed. See Offer Flight Upgrade.
Checkout flight selection (PUT /checkout/flights) DomesticLegSelectionReconciler compares the selected fare’s landing day (persisted server-side as fare_details.*.outbound_arrival_date) with the metadata domestic legs; off-schedule legs are re-derived from cache or the selection is refused with a 422 on fare_id. See Checkout API.
Issuance (CheckoutFlightBookingService::bookDomesticLeg()) Last line of defence: a domestic leg whose stored date is not the booked international arrival + day_offset fails as domestic_date_mismatch before any supplier call, with both dates in the failure metadata. Never retried.
ProductByMarketFlightConfig::replaceLegs() When a domestic leg’s route or day_offset changes, every published offer of that config is withdrawn to Draft (withdrawOffersOutlivingItinerary()); the generator recreates offers for the new itinerary. The admin edit page reports the count.
offers:audit-domestic-dates (daily, 06:45 Madrid, --notify) Read-only audit of future-dated offers and live bookings; non-zero exit and an admin database notification when any leg is off its tour date. --status (active by default, also draft or all) keeps the scheduled run on sellable inventory while letting an operator reach the offers withdrawOffersOutlivingItinerary() has just pushed back to Draft — precisely the ones a leg edit drifts — and verify a draft backfill.

Repair tooling: offers:fix-domestic-dates rebinds drifted offer legs (skipping offers with bookings — pass --ignore-carts to let abandoned or in-progress checkout carts through, they hold their own copy of the legs — and queueing the cache search for legs whose correct date has no fare yet) and bookings:replace-domestic-legs {booking} --leg=index:cacheId rewrites a booking’s frozen flight_selection from a cache row that sits exactly on the tour date. Both need --apply to persist their repair, but offers:fix-domestic-dates is not read-only without it — the cache-search queueing above runs either way. Only bookings:replace-domestic-legs writes a per-person fare_price, dividing the cache total by the offer’s reference pax (Offer::getPaxCount()) and never by the party size; the rebind stores the cache total on the binding instead.

offers:fix-domestic-dates repairs only offers that have not departed (offersInScope(), the same departure_date >= today filter the audit uses): a departed offer sits below the booking lead-time floor Offer::scopeBookable() enforces (departure_date >= today + BOOKING_LEAD_TIME_DAYS) and can never be sold, so rebinding it would reprice dead inventory — on production it removed 1,653 offers from the run and, more to the point, stopped 280 pending cache entries being queued for dates that had already flown. --price-alert=500 lists in a second table, worst delta first, the legs whose tour-correct fare costs at least that much more than the drifted one. It is purely informational: every leg still rebinds, because the tour date is not negotiable. A jump that size normally means the correct day has no sensible service — an operator call on whether the departure stays on sale, not a repair decision. On CHC→PPT the correct Tuesday has no Air New Zealand flight, so the only cached option is a ~4,000 EUR multi-carrier connection against ~630 EUR on the drifted Monday.

CUG Pairing Between International and Domestic Legs

Section titled “CUG Pairing Between International and Domestic Legs”

findDomesticFlights() does not require strict cug_type equality between the international and domestic candidates. Domestic carriers in many markets (e.g. Vietnam Airlines / VietJet on internal sectors) only sell public (cug=ALL) fares, so requiring a TOP international to pair with a TOP domestic would yield zero matches even when valid combinations exist. FlightRankingPolicy still picks the best domestic candidate within the date window. The international leg’s cug_type is propagated onto the offer (and onto the domestic OfferFlight rows), so the resulting offer correctly reflects the international’s CUG.

Source: backend/app/Services/Offers/AutoOfferGeneratorService.php (findDomesticFlights())

Each ProductByMarketFlightLeg carries optional departure_time_from / departure_time_to fields (HH:MM, airport-local clock). The window is defined per-leg by the operator on the parent ProductTemplate itinerary’s RouteSegments editor and is propagated onto each ProductByMarketFlightLeg when the config is generated.

Aerticket /search does not accept a server-side time-of-day filter, so every fare for the date is stored in dynamic_flight_caches and the window is enforced client-side at the SQL layer when the offer generator looks up candidates:

// AutoOfferGeneratorService::findDomesticFlights()
->when(
$departureTimeFrom !== null || $departureTimeTo !== null,
function ($q) use ($departureTimeFrom, $departureTimeTo): void {
$q->whereHas('segments', function ($segQ) use ($departureTimeFrom, $departureTimeTo): void {
$segQ->where('leg_sequence', 1)
->where('itinerary_index', 1)
->where('segment_number', 1);
if ($departureTimeFrom !== null) {
$segQ->whereTime('departure_time', '>=', $departureTimeFrom);
}
if ($departureTimeTo !== null) {
$segQ->whereTime('departure_time', '<=', $departureTimeTo);
}
});
},
)
->orderBy('total_price')
->limit(10);

Why this matters: the window must be applied before the orderBy(total_price) → limit(10) slice, not after. If the 10 cheapest globally are all off-window (e.g. LIM→CUZ at 04:50 €238, ten consecutive fare_positions), a post-pass PHP filter would empty the slice and the offer would be skipped with Missing domestic leg. With the filter in SQL the limit applies to in-window rows, so the cheapest in-window candidate (e.g. €255 at 10:10, fare_position=23) is returned.

The window is checked against the first outbound segment’s departure_time (leg_sequence=1, itinerary_index=1, segment_number=1) because that’s the leg the operator anchors the window on. Either bound can be null independently — from='09:00' with no upper bound accepts any departure ≥ 09:00; to='13:00' with no lower bound accepts any departure ≤ 13:00.

Source: backend/app/Services/Offers/AutoOfferGeneratorService.php (findDomesticFlights())

Operators investigating missing offers can run a read-only, on-demand diagnostic from the admin to find out why the auto-generator skipped one or more (ProductByMarket, departure_airport, departure_date) slots. The action accepts multi-select airports + multi-select dates and runs the per-slot diagnostic for the Cartesian product. The generator silently skips fares failing any of its gates — this action mirrors every gate and reports per-gate outcomes instead of acting on them.

Header action on the ProductByMarket View page (URL pattern admin/products-by-market/product-by-markets/{id}), registered between “View Cached Flights” and “Edit”. Two-step modal:

  1. Form modal — operator picks airports (multi-select, scoped to the PBM’s active flight configs, empty by default) and dates (multi-select, search-window-minus-blackouts list mirroring the picker used by DynamicFlightCacheManager).
  2. Result modal — mounted via registerModalActions, runs the diagnostic for each (airport, date) cell synchronously and renders a summary header (X slots · Y would create · Z blocked) plus one compact row per slot. Each row carries the verdict badge; blocked rows list every failing gate (not just the first) with their reasons.

A hard cap (DiagnoseMissingOffersAction::MAX_CELLS, currently 100) refuses runs where airports × dates exceeds the limit and surfaces a Filament notification instead — keeps synchronous execution under the default PHP/Filament request timeout.

Source: backend/app/Filament/Resources/ProductsByMarket/Actions/DiagnoseMissingOffersAction.php, backend/app/Filament/Resources/ProductsByMarket/Pages/ViewProductByMarket.php, backend/resources/views/filament/modals/bulk-offer-generation-diagnostic.blade.php

Gates run in the same order as the generator and stop short only for early-exit verdicts (already_exists, capped). The full ordered list is 21 gates:

# Gate Category Source of truth
1 flight_config_exists Diagnostic-only n/a (precondition)
2 supplier_tour_configured Diagnostic-only n/a (precondition)
3 rate_periods_configured Diagnostic-only n/a (precondition)
4 market_currency_configured Diagnostic-only n/a (precondition)
5 cache_route_exists Diagnostic-only DynamicFlightCacheRoute lookup
6 cache_row_exists Diagnostic-only DynamicFlightCache lookup
7 cache_completed Mirrored status = completed filter
8 cache_has_data Diagnostic-only segments not empty AND total_price > 0
9 cache_not_expired Mirrored expires_at null or future
10 cache_baggage_policy Mirrored Setting flights.search.baggage_policy
11 cache_arrival_date_resolved Mirrored arrival_date populated
12 cache_not_already_offered Direct call (generator) findUnlinkedFlightsofferFlights empty
13 passes_max_layover Direct call FlightRankingPolicy::passesMaxLayoverCache
14 passes_min_return_departure Direct call FlightRankingPolicy::passesMinReturnDepartureCache
15 passes_nights_at_destination Direct call FlightRankingPolicy::passesNightsAtDestinationCache
16 passes_latest_arrival_cutoff Direct call DynamicFlightCache::violatesLatestArrivalTime
17 domestic_leg_<idx>_<from>_<to> (one per leg) Mirrored per-leg classifier (4 buckets — see below)
18 rate_period_covers_arrival_date Mirrored SupplierTourRate::isDateAvailable (replaces applyOperatingPeriodsDateFilter SQL with a single-date weekday/excluded-range check)
19 land_price_computable Direct call (generator) AutoOfferGeneratorService::calculateLandPrice + SupplierTourBlackoutResolver::describeHit for the reason
20 per_date_offer_cap Mirrored AutoOfferGeneratorService::MAX_OFFERS_PER_DATE
21 carrier_diversity Mirrored outboundCarrierKey + extractCarriersFromOffers mirrors

Direct call = no duplicated logic, zero drift risk (gate invokes the generator/policy method).

Mirrored = duplicated PHP because the generator keeps the method private/protected. Listed in OfferGenerationDiagnosticsService’s class-level docblock with source line numbers.

Diagnostic-only = the generator would silently produce “no flights” or fail far downstream; the diagnostic surfaces a more useful reason up front.

The per-leg domestic classifier (gate 17) emits one of four buckets — route_missing, pending_entries_missing, cache_empty_after_search, domestic_window — mirrored from AuditOfferCacheGapsCommand (lines 258-336).

The result modal shows one of four verdict strings:

Verdict When
would_create All gates pass — the generator would create an offer for this slot on its next run.
already_exists The cache_not_already_offered gate failed (every surviving cache row is already linked to an offer).
capped The per_date_offer_cap gate failed (existing offer count for the slot is already at MAX_OFFERS_PER_DATE).
blocked_at_<gate_name> First failing gate that is neither of the above (e.g. blocked_at_passes_max_layover).

Two failure modes that look identical in the cache

Section titled “Two failure modes that look identical in the cache”

A status=completed cache row with zero segments and zero total_price has two distinct root causes that look the same on disk. The cache_has_data gate surfaces this state explicitly, but the diagnostic cannot distinguish which of the two happened — only the category.

  1. Aerticket returned zero fares for the OD/dates (! $response->hasResults() at backend/app/Services/Flights/DynamicFlightCachePopulatorService.php:673-691).
  2. Aerticket returned fares but all were rejected by the populator’s filter chain — passesMaxLayoverFare / passesMinReturnDepartureFare / passesDepartureWindowFare / passesNightsAtDestinationFare plus baggage resolution ($resolved->isEmpty() at backend/app/Services/Flights/DynamicFlightCachePopulatorService.php:1037-1049).

Re-caching later may surface fares when supplier inventory changes; the populator does not write per-fare rejection logs to the cache row.

  • cache_has_data reason is structural (see above) — names the category, never the specific fare.
  • passes_nights_at_destination reason names the target nights count but not the actual per-row delta.
  • rate_period_covers_arrival_date reason doesn’t classify why (out-of-range vs. weekday vs. excluded_date_ranges hit).
  • land_price_computable non-blackout branch is generic (“missing room-type price or exchange rate”) — the blackout branch carries service/rate/range detail from SupplierTourBlackoutResolver::describeHit. The activation guard resolves the non-blackout causes precisely (which package, which rate, zero price vs. missing FX); the diagnostics panel does not.

Source: backend/app/Services/Offers/OfferGenerationDiagnosticsService.php, backend/app/Services/Offers/DTOs/DiagnosticReport.php, backend/app/Services/Offers/DTOs/DiagnosticGateResult.php, backend/app/Services/Offers/DTOs/DiagnosticGateStatus.php