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)”

A different cache row beats the bound one under FlightRankingPolicy. 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.
  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.

Trigger rule (per leg):

  • Candidate wins under FlightRankingPolicy::compareFlightCache → upgrade.
  • Candidate ties under the policy AND is strictly cheaper → upgrade.
  • Otherwise → keep current.

The $candidate MUST have its segments relation eager-loaded — the policy comparator reads them.

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::attemptInternationalUpgrade resolves the policy winner from the live search response, calls findCacheRowMatchingFare to load the matching cache row with segments, and dispatches upgradeIfBetter. After the upgrade (or when no upgrade fires), EconomyFlightCacheUpdateService::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.