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
Two regimes: draft vs locked
Section titled “Two regimes: draft vs locked”The service behaves differently depending on whether the offer has been activated.
| Regime | final_price_locked_at | Behaviour |
|---|---|---|
| Draft | NULL | Owned by AutoOfferGeneratorService. Recomputes behave like a fresh generator pass — final_price rises or falls to match the new flight prices. No floor. |
| Locked | timestamp set at activation | The 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.
Two entry points
Section titled “Two entry points”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:
- Marks the prior
offer_flight_bindingsrow as replaced (is_current = false,replaced_at = now). - Inserts a new current binding pointing to
$candidatewith the suppliedsource. - Syncs the
offer_flightsprojection so existing read paths see the new fare (re-snapshots the candidate’s primary outbound/return itinerary indices — see #1806). - Recomputes
final_priceunder the floor rule. - Appends a snapshot to
offer_price_snapshotslinked to the new binding viatriggered_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_flightsprojection when possible, then proceeds with the rebind. Returnsfalseonly when neither binding nor projection exists. - Idempotent no-op — already bound to
$candidate->id→ returnsfalse. - Pareto price guard — upgrades only when the candidate beats the
current according to
FlightRankingPolicy::compareFlightCacheafter the best available stop-count tier has been selected (duration → price → stops → arrival within that tier). $bypassPriceGuardparameter — checkout sets this totruefor the live selected fare. Other callers may leave itfalseto preserve the local Pareto guard.
Checkout binding scope
Section titled “Checkout binding scope”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:
- Sums per-leg prices to recompute
flight_base_priceandbase_price. - Applies the floor rule.
- Updates the
offersrow via raw query builder (bypassingOfferObserver’s publish-lock on activated offers, which would otherwise block the audited recompute). - 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.
The floor rule
Section titled “The floor rule”When final_price_locked_at IS NOT NULL:
- Cheaper recompute —
final_priceis held at its prior value.extra_margin_captured = final_price - would_be_final_at_policy_margin.effective_margin_pctrises above the stickerpolicy_margin_pct. - Pricier recompute —
final_pricerises to the newwould_be_final_at_policy_margin.effective_margin_pctreturns 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.
Worked example
Section titled “Worked example”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:
| 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,700 (held) | €340 | 22.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.
Source / reason taxonomy
Section titled “Source / reason taxonomy”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.
Integration points
Section titled “Integration points”- Customer checkout —
EconomyFlightSearchService::rankDisplayFaresfilters and ranks the live response, thenbindFirstDisplayFarelocates / creates a matching cache row viafindOrCreateCacheRowForFareand dispatchesupgradeIfBetter(..., bypassPriceGuard: true). Before ranking,EconomyFlightCacheUpdateService::refreshSignatureMatchedRowsupdates price columns on every cache row in the offer’s route+departure+CUG bucket whose per-leg signature matches a live fare. After binding,updateCacheAndOfferPricerefreshes per-leg prices and dispatchesrecordPriceRefresh. - 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.
Related
Section titled “Related”- Offers — pricing formula and lifecycle.
- Offers History (database) — schema for
offer_flight_bindingsandoffer_price_snapshots. - Flight Search —
FlightRankingPolicycomparator used by the trigger rule. - Dynamic Flight Cache — the cache rows bindings point at.