Skip to content

Offer Flight Upgrade

OfferFlightUpgradeService owns every audited price write on an activated offer at checkout time. Whenever a live flight search returns a better fare than the one currently bound to an offer, this service swaps the binding, recomputes the price under a floor rule, and appends a row to the offer’s price history.

It works alongside the append-only history tables documented under Offers History (database).

Source: backend/app/Services/Offers/OfferFlightUpgradeService.php

The service behaves differently depending on whether the offer has been activated.

Regimefinal_price_locked_atBehaviour
DraftNULLOwned by AutoOfferGeneratorService. Recomputes behave like a fresh generator pass — final_price rises or falls to match the new flight prices. No floor.
Lockedtimestamp set at activationThe customer-facing final_price is floored. A cheaper-flight refresh keeps the price and grows margin instead. A pricier-flight refresh raises final_price and returns margin to sticker.

The lock timestamp is set in OfferObserver::updating when the offer transitions to Active. Existing activated offers were backfilled from their activated_at column — see migration 2026_05_05_091105_add_final_price_locked_at_to_offers.php.

upgradeIfBetter(Offer $offer, int $legIndex, DynamicFlightCache $candidate, string $source)

Section titled “upgradeIfBetter(Offer $offer, int $legIndex, DynamicFlightCache $candidate, string $source)”

The caller has already chosen $candidate as the winner under its own ordering. The service:

  1. Marks the prior offer_flight_bindings row as replaced (is_current = false, replaced_at = now).
  2. Inserts a new current binding pointing to $candidate with the supplied source.
  3. Syncs the offer_flights projection so existing read paths see the new fare (re-snapshots the candidate’s primary outbound/return itinerary indices — see #1806).
  4. Recomputes final_price under the floor rule.
  5. Appends a snapshot to offer_price_snapshots linked to the new binding via triggered_by_binding_id.

Caller filters the policy filters; the service can re-apply the Pareto price guard against the current binding. Max layover, min return departure, departure window, nights-at-destination, baggage, and visual deduplication live in the checkout flow — see Flight Search — Checkout live winner binding. Checkout passes bypassPriceGuard=true because the fare being bound is the same fare rendered as “Seleccionado”; keeping a different persisted binding would make admin and checkout disagree.

Active guards:

  • Missing current binding — bootstraps from the existing offer_flights projection when possible, then proceeds with the rebind. Returns false only when neither binding nor projection exists.
  • Idempotent no-op — already bound to $candidate->id → returns false.
  • Pareto price guard — upgrades only when the candidate beats the current according to FlightRankingPolicy::compareFlightCache after the best available stop-count tier has been selected (duration → price → stops → arrival within that tier).
  • $bypassPriceGuard parameter — checkout sets this to true for the live selected fare. Other callers may leave it false to preserve the local Pareto guard.

Economy checkout first computes the display-ranked fares in EconomyFlightSearchService::rankDisplayFares. That method enforces baggage on both international legs via native baggage or in-response siblings, max stops, max layover, min return departure, departure window, nights-at-destination, and visual deduplication. bindFirstDisplayFare then binds the first ranked fare that can be represented by a cache row. That bound fare is pinned as the rendered “Seleccionado” card.

This means a live response cannot show one selected flight while admin keeps another: if a fare cannot be materialized and rebound, it cannot be the selected display card.

Known limitation — matchSource = 'sibling' on the bound flight: when resolveOfferMatchedFareWithBag finds a bag-included fare-family for the same physical flight already cached as bag=N, the bound cache row stays baggage_included = false. The customer is unaffected (display, price, and booking-time matching all use the sibling), but the persisted cache state remains stale. Fix path — update the bag columns in place via EconomyFlightCacheUpdateService::updateFlightCache rather than forcing a rebind to a different physical flight — is tracked separately so the antibug guard from #1707 stays intact.

The audited price-floor behaviour is unchanged: a cheaper rebind still holds final_price and records the savings as extra_margin_captured; a pricier rebind raises final_price to the new would-be sticker price.

The $candidate MUST have its segments relation eager-loaded — the projection sync reads the primary itinerary indices off it.

recordPriceRefresh(Offer $offer, string $reason)

Section titled “recordPriceRefresh(Offer $offer, string $reason)”

The bound flight didn’t change but its price did (e.g. the same fare came back from the live search at a new total). The caller — EconomyFlightCacheUpdateService — has already written fresh per-leg prices to the offer_flights projection. This method then:

  1. Sums per-leg prices to recompute flight_base_price and base_price.
  2. Applies the floor rule.
  3. Updates the offers row via raw query builder (bypassing OfferObserver’s publish-lock on activated offers, which would otherwise block the audited recompute).
  4. Appends a snapshot tagged with $reason.

Idempotency: if the recomputed flight_base_price and final_price match the latest snapshot within €0.01, no row is written. Polling/refreshes during the same checkout flow do not pile up audit rows.

When final_price_locked_at IS NOT NULL:

  • Cheaper recomputefinal_price is held at its prior value. extra_margin_captured = final_price - would_be_final_at_policy_margin. effective_margin_pct rises above the sticker policy_margin_pct.
  • Pricier recomputefinal_price rises to the new would_be_final_at_policy_margin. effective_margin_pct returns to sticker. extra_margin_captured = 0.

Drafts skip the floor entirely: final_price = would_be_final regardless of direction.

The “would-be” value is computed using the same per-pax marketing rounding the generator and OfferObserver use, so the locked recompute matches what a fresh generator run would have produced.

Activated offer #16216, original binding to cache 70734 (Vueling+TK, flight cost €2,915.74). A live checkout search fired and the FlightRankingPolicy winner was cache 75240 (TK 17:55 / SA 14:20, flight cost €2,618.34). The result:

RecordedReasonFlight baseFinalCapturedEffective margin
(generation)generated€3,567.56€17,700€020.02%
(live search)live_search_upgrade€3,270.16€17,700 (held)€34022.41%

The customer-visible price never changed. The flight cost dropped €297. €340 was captured as extra margin — the gap between the would-be €17,360 (at sticker margin) and the floor €17,700.

The source column on offer_flight_bindings and the reason column on offer_price_snapshots distinguish who triggered each row.

Source / ReasonWhere it fires
generator (binding) / generated (snapshot)AutoOfferGeneratorService writes the original binding and the first price snapshot when an offer is created.
live_search_upgrade / live_search_refreshCustomer-facing checkout live search via EconomyFlightSearchService and EconomyFlightCacheUpdateService.
arkana_search_upgrade / arkana_search_refreshAdmin “Recalculate offer” button on the offer view page (ViewOffer::recalculateOfferAction).
manualReserved for future admin direct-edit flows.

The admin and customer paths share the exact same services — the only difference is the tag — so admin-triggered and customer-triggered upgrades produce identical history rows aside from this label.

  • Customer checkoutEconomyFlightSearchService::rankDisplayFares filters and ranks the live response, then bindFirstDisplayFare locates / creates a matching cache row via findOrCreateCacheRowForFare and dispatches upgradeIfBetter(..., bypassPriceGuard: true). Before ranking, EconomyFlightCacheUpdateService::refreshSignatureMatchedRows updates price columns on every cache row in the offer’s route+departure+CUG bucket whose per-leg signature matches a live fare. After binding, updateCacheAndOfferPrice refreshes per-leg prices and dispatches recordPriceRefresh.
  • Admin “Recalculate offer” — header action on the offer view page. Runs the same two services with the arkana_search_* tags. Available on drafts (no floor) and activated offers (floor applies). Surfaces a notification listing which legs were upgraded.