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% | 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.
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 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
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 ServiceInsurance Price = whole-party cost of the market's included base policy (0 when none)Base Price = Total Flight Price + Land Price + Insurance PriceMargin% = 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 CountMargin Basis (Margin-on-Sale)
Section titled “Margin Basis (Margin-on-Sale)”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
costwhen the column was introduced, so nothing repriced at deploy. New offers (auto-generated and manual) default tosale(DB default +Offer::$attributes). - The basis is frozen into the checkout session at
start()(session keymargin_basis, next tomargin) and travels into the booking snapshot. Sessions/snapshots written before the field existed resolve tocostviaMarginBasis::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);OfferObserverrejects ≥ 100 and the admin forms cap the input at 90. - Financial model: COGS aggregation uses a SQL
CASE WHENonoffers.margin_basis, so mixed cost/sale fleets aggregate correctly. The average unit-economics model interpretsconfig('financial.margin_percent')perconfig('financial.margin_basis')(kept atcostuntil 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:
# Dry-run (default): per-offer old → new price deltas, nothing writtenphp artisan offers:migrate-margin-basis --market=ES
# Persist; drafts + approved offers only by defaultphp artisan offers:migrate-margin-basis --market=ES --apply
# Active offers require --status=active plus an interactive confirmationphp artisan offers:migrate-margin-basis --status=active --applyFilters: --market=, --product=, --offer=, --status=*. Booked offers are
gated in two tiers:
- Collected first payment — an offer with any live booking that has a
Succeededpayment (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
Included Insurance Component
Section titled “Included Insurance Component”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())
Margin 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 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
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 - The bundled insurance component scales linearly too:
(offer.insurance_base_price / 2) x actual_pax_count(the base policy is flat per pax) - 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.
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.
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, 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 totalPer-pax raw: €4,947.33 / 2 = €2,473.66Per-pax rounded: €2,470 (nearest multiple of 10)Final price: €2,470 × 2 = €4,940Example 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 totalPer-pax raw: €7,420.99 / 3 = €2,473.66Per-pax rounded: €2,470 (nearest multiple of 10)Final price: €2,470 × 3 = €7,410Legacy 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 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 Pricing & Rounding
Section titled “Extras Pricing & Rounding”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
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 | 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.
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.
Stop Sale (Derived State)
Section titled “Stop Sale (Derived State)”“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())
Release-Window Auto-Draft
Section titled “Release-Window Auto-Draft”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:
Activeoffers inside the window with no committed booking move toDraft. 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()) stayActive, 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
Recalculating Prices
Section titled “Recalculating Prices”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
SupplierObservercascade 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, $eurCurrency) — per-component currency conversion at the latest FX rate (only future-dated offers are touched, so “latest rate” is correct). 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. |
Dry Run vs Apply
Section titled “Dry Run vs Apply”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 |
Guards
Section titled “Guards”- Committed bookings: offers with a committed booking are skipped. “Committed” is the new
Booking::scopeCommitted()— any booking not inDraft,Checkout,Cancelled, orExpired. 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
nullor ≤ 0 (missing rate or FX), the offer is skipped — the command never overwrites a real price with 0.
Active Offers
Section titled “Active Offers”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. offers.final_price_locked_at is deliberately left untouched — recalculation is not a status transition.
Audit Trail
Section titled “Audit Trail”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.
This command does not fix overlapping rate periods.
Source: backend/app/Console/Commands/RecalculateOfferPricesCommand.php, backend/app/Observers/OfferObserver.php, backend/app/Models/Booking.php (scopeCommitted())
Pricing Visibility (Supplier Managers)
Section titled “Pricing Visibility (Supplier Managers)”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.
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.
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. 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.
Bookability
Section titled “Bookability”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 = Activedeparture_date >= today + 5 daysNOT EXISTSa stop-sale row on the offer’s tour whose[start_date, end_date]window coversoffers.departure_dateNOT EXISTSa covering package-service rate that is violated — a rate whose[start_date, end_date]window coversoffers.departure_dateand whosedeparture_date < today + release_days(strict<, so a departure exactlyrelease_daysaway 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)
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 (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 scopesOffer::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 sourcegetReturnDate() 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).
Auto 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 that already match the leg’s configured departure window (see Departure Window Filtering), 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.
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.
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())
Departure Window Filtering
Section titled “Departure Window Filtering”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())
Diagnose Missing Offers
Section titled “Diagnose Missing Offers”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.
Where it lives
Section titled “Where it lives”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:
- Form modal — operator picks
airports(multi-select, scoped to the PBM’s active flight configs, empty by default) anddates(multi-select, search-window-minus-blackouts list mirroring the picker used byDynamicFlightCacheManager). - 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
What it checks
Section titled “What it checks”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) | findUnlinkedFlights — offerFlights 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 + mirrored describeBlackoutHit 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).
Verdicts
Section titled “Verdicts”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.
- Aerticket returned zero fares for the OD/dates (
! $response->hasResults()atbackend/app/Services/Flights/DynamicFlightCachePopulatorService.php:673-691). - Aerticket returned fares but all were rejected by the populator’s filter chain —
passesMaxLayoverFare/passesMinReturnDepartureFare/passesDepartureWindowFare/passesNightsAtDestinationFareplus baggage resolution ($resolved->isEmpty()atbackend/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.
Known limitations of the reason strings
Section titled “Known limitations of the reason strings”cache_has_datareason is structural (see above) — names the category, never the specific fare.passes_nights_at_destinationreason names the target nights count but not the actual per-row delta.rate_period_covers_arrival_datereason doesn’t classify why (out-of-range vs. weekday vs.excluded_date_rangeshit).land_price_computablenon-blackout branch is generic (“missing room-type price or exchange rate”) — the blackout branch carries service/rate/range detail via the mirroreddescribeBlackoutHit.
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
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