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.

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.