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 different fare than the one currently bound to an offer, this service swaps the binding, recomputes the price to the current flight cost at the sticker margin, and appends a row to the offer’s price history. There is no floor: a cheaper flight lowers the customer-facing price, a pricier flight raises it.

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.

Regime final_price_locked_at Behaviour
Draft NULL Owned by AutoOfferGeneratorService. This service is a no-op — recomputes are handled by the generator.
Activated timestamp set at activation This service acts. final_price tracks the current flight cost at the sticker margin in both directions: a cheaper-flight refresh lowers the customer-facing price, a pricier-flight refresh raises it. extra_margin_captured is always 0 and effective margin always equals the policy margin.

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 to the current flight cost at the sticker margin.
  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.

Rebinding leg 0 changes the day the traveller lands at the destination whenever the candidate is an overnight connection and the current binding is same-day (or vice versa). Every domestic leg’s date is landing day + day_offset (Domestic Leg Date Derivation), so before the transaction opens upgradeIfBetter asks DomesticLegRebindPlanner::planForInternational() what must happen to them:

  • Nothing — the offer has no domestic legs, or every domestic binding already sits on the candidate’s landing day + offset.
  • Rebind — for each off-schedule domestic leg the planner runs the generator’s own selection (AutoOfferGeneratorService::findDomesticFlightsForArrival(), exact date, departure window, baggage preference, same-day and arrival-cutoff filters) and the service swaps that binding in the same transaction as the international, with source = intl_rebind_cascade, then reprices once on the new set of legs. Legs that were already drifted are repaired on the way.
  • Blocked — a domestic leg has no compliant fare cached on its new date. The international rebind does not happen (upgradeIfBetter returns false, the caller moves on to its next candidate) and the missing (route, date) is created as a pending cache entry so the populator searches it and the next attempt can succeed.

This closes the gap that drifted 417 active offers in Aug–Sep 2026: checkout live search and the admin “Recalculate” bulk action kept swapping the international flight while the domestic bindings stayed on the previous arrival’s dates. An offer can no longer be left with domestic legs that belong to a different international landing day.

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 recompute tracks flight cost in both directions: a cheaper rebind lowers final_price to the new sticker-margin price, a pricier rebind raises it. extra_margin_captured stays 0 in both cases.

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 re-derives base_price as flight + land + the bundled included-insurance component (insurance_base_price) — the same invariant OfferObserver enforces.
  2. Recomputes final_price to the current cost at the sticker margin (same per-pax marketing rounding the generator uses).
  3. Writes the offers row directly via DB::table('offers')->update(...), bypassing OfferObserver, and mirrors the new values onto the in-memory model so callers see them without a refresh.
  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, the recompute always sets final_price to the current flight cost at the sticker margin — regardless of direction:

  • Cheaper recomputefinal_price drops to the new sticker-margin price.
  • Pricier recomputefinal_price rises to the new sticker-margin price.

In both cases effective_margin_pct equals the policy policy_margin_pct and extra_margin_captured = 0. There is no floor.

Historically a one-way ratchet held the price on cheaper flights and banked the delta as extra_margin_captured. That floor was removed (#2149) so offers always reflect real flight cost. Per-reload price volatility during a single checkout is instead dampened by a session-scoped price lock in the checkout layer (CheckoutSessionService’s quoted_price_locked on the booking quote), not by this service.

The sticker-margin price is computed using the same per-pax marketing rounding the generator and OfferObserver use, so the 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) — a cheaper fare. The result:

Recorded Reason Flight base Final Captured Effective margin
(generation) generated €3,567.56 €17,700 €0 20.02%
(live search) live_search_upgrade €3,270.16 €17,360 (lowered) €0 20.02%

The flight cost dropped, so the customer-facing final_price dropped with it to the sticker-margin price (~€17,360). Nothing is captured; effective margin stays at the policy margin.

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

Source / Reason Where 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_refresh Customer-facing checkout live search via EconomyFlightSearchService and EconomyFlightCacheUpdateService.
arkana_search_upgrade / arkana_search_refresh Admin “Recalculate offer” button on the offer view page (ViewOffer::recalculateOfferAction).
manual Reserved 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. On drafts the generator owns pricing (this service is a no-op); on activated offers the audited recompute runs. Surfaces a notification listing which legs were upgraded.