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)”A different cache row beats the bound one under
FlightRankingPolicy. 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. - Recomputes
final_priceunder the floor rule. - Appends a snapshot to
offer_price_snapshotslinked to the new binding viatriggered_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:
- 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::attemptInternationalUpgraderesolves the policy winner from the live search response, callsfindCacheRowMatchingFareto load the matching cache row with segments, and dispatchesupgradeIfBetter. After the upgrade (or when no upgrade fires),EconomyFlightCacheUpdateService::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.