Skip to content

Last Seats

Last Seats (“Últimas plazas” on the web) is a curated showcase of products whose specific departure dates are sold at a discount, limited by a seat quota per departure. It spans three public surfaces (a navbar link, a home band and a dedicated page), one admin screen, and a supplier-facing cost breakdown on the booking.

The discount is a percentage off the supplier land price only. Flights, the included insurance and the extra-night supplement are untouched.

Configuration is per market. Spain is the only market configured today; the data model is market-scoped from the start, so opening another market is a row rather than a refactor. The admin screen opens on the Spanish section and reloads when its Market select changes, so each market’s showcase is edited and saved on its own.

  • Selling a fixed number of seats on chosen departure dates at a reduced price the supplier has agreed to
  • Publishing that offer as a standalone page plus a navbar link and a home band
  • Showing a supplier what a discounted booking is worth to them, in their own currency

Not for permanent price changes (that is the offer’s margin — see Offers), and not for capacity (that is Allotment: the campaign quota is a commercial subset of contracted seats, never extra inventory).

LastSeatsSection (one per market — cover_image, max_discount_percent,
│ is_active, starts_at, ends_at)
└── LastSeatsProduct (product_by_market_id, comparison_price, card_photo, sort_order)
└── LastSeatsPeriod (starts_on, ends_on, discount_percent, title, campaign_seats)
└── LastSeatsDeparture (departure_date, applied_discount_percent)
└── LastSeatsSeatConsumption (booking_id, seats, discount_percent,
land_discount_amount, supplier_land_costs)

supplier_land_costs (jsonb) freezes what each supplier’s land cost was on the day the trip was sold, in that supplier’s own currency: Volāre pays the rate agreed at the time of sale, so a tariff change afterwards must not move what is owed for a booking already made. Bookings recorded before this existed have no entry and are re-quoted from today’s rates, which is all that can be known about them. The split of the discount between suppliers is not stored — it is derived from each one’s share of the land cost.

Table Holds Key constraint
last_seats_sections Per-market switch, schedule, cover, headline % market_id unique
last_seats_products One showcased product and its comparison price (section, product_by_market) unique
last_seats_periods A date range and the terms its departures share Indexed by (product, starts_on)
last_seats_departures One ticked departure date + what the offers currently hold (product, departure_date) unique
last_seats_seat_consumptions Seats a paid booking took, at the frozen discount (departure, booking) unique

Discount periods are how the campaign is expressed: a date range, the offers (departure dates) ticked inside it, and one percentage, one supplier-facing title and one seat quota shared by all of them. A product needs a second period whenever any of those three differ. The quota is per departure, not per period — every ticked date gets its own quota of campaign_seats, sells out on its own, and returns to its normal price on its own while the rest stay on campaign.

last_seats_departures.last_seats_product_id is denormalised from the period purely so the database can enforce that a departure date belongs to at most one period of a product.

A departure is a date, not an offer row: every offer on that date, across all departure airports and room types, shares the campaign and its quota.

Source: backend/app/Models/LastSeatsSection.php, LastSeatsProduct.php, LastSeatsPeriod.php, LastSeatsDeparture.php, LastSeatsSeatConsumption.php

CampaignDiscountResolver::resolve(ProductByMarket, departureDate) answers “is this departure on campaign right now, and by how much”. It returns null — normal price — when any of these is true: no section for the market, the section is not visible, the product is no longer showcased, the date is not a campaign departure, or the quota is spent. Lookups are memoized per resolved instance; the service is registered scoped in AppServiceProvider, so that means per request or per queued job (the trip configurator calls land pricing up to twenty times for one response).

AutoOfferGeneratorService::calculateLandPrice() takes an optional final ?ProductByMarket $productByMarket parameter. Passing it opts the date into the campaign; it is the one thing the calculation cannot derive from the tour, since a tour is shared by every market while a campaign belongs to one.

In the package branch the percentage is applied to each supplier service price in the supplier’s own currency, before FX conversion — converting first would produce the same euros but leave nothing to show the supplier in their currency. The itemized hotel/activity branch (effectively dormant — every product with an open search window prices through packages) applies the factor after conversion instead, so that rationale does not hold there.

The extra-night supplement is deliberately excluded — it is quoted case by case and was never part of the deal. It is added to the package land total after the campaign factor has been applied.

A campaign never makes calculateLandPrice() return null. null means “this date cannot be sold” everywhere in the system; a spent quota only means the discount is over.

Quota arithmetic (travellers, not bookings)

Section titled “Quota arithmetic (travellers, not bookings)”

The quota counts travellers. A party larger than the remaining seats is charged the campaign price for the seats it takes and the normal price for the rest, as a single blended per-person price:

effective_percent = discount_percent × min(travellers, seats_left) / travellers
land_price = land_price × (1 − effective_percent / 100)

The total is the same as splitting the booking, without the system having to carry two different per-person prices. The quota belongs to the booking, not to a room or an offer row — a party in two double rooms takes four seats, not two lots of two — so both checkout and the trip configurator apply the discount once to the party’s total land cost rather than per room.

Source: backend/app/Services/LastSeats/CampaignDiscount.php, CampaignDiscountResolver.php, CampaignSeatService.php

Caller Route to the discount
AutoOfferGeneratorService::generateForFlightConfig() Passes the product to calculateLandPrice()
RecalculateOfferPricesCommand Passes the product (both the normal and the --package-date-fix-only comparison call)
OfferActivationGuard (saleShortfall(), reasonBlocking()) Passes the product, so the below-cost gate compares like with like
CreateOffer (admin wizard) Passes the product — a manual offer on a campaign date prices like a generated one
CheckoutSessionService (non-standard pax/rooms) Prices each room without the product, then applies CampaignSeatService::applyToLandPrice() once to the party’s total
CheckoutSessionService (standard 2A) Re-prices nothing — takes offers.final_price / land_base_price as they stand, already discounted by the sync
TripConfiguratorResource Per-room land, then one discount on the configuration total

Checkout and the configurator take the second route on purpose. The quota belongs to the booking, not to a room: pricing each room through the campaign would hand a party in two double rooms four discounted seats out of a quota of three.

CampaignSeatService::snapshot() runs on both checkout branches, so a standard 2A checkout still freezes the campaign it was quoted under even though it never re-prices.

FromPriceCalculator also omits the product, then applies the percentage being edited itself, so an unsaved Arkana value previews correctly. The diagnostic callers (OfferGenerationDiagnosticsService, offers:diagnose) omit it deliberately: they measure whether the supplier data can price at all, not what Volāre currently charges.

Every public surface — product page, trip configurator, the card “from” price, standard checkout — reads the prices persisted on offers (marketing_price_per_pax, land_base_price, final_price). A campaign resolved only at calculation time therefore changes nothing until those rows are rewritten.

SyncLastSeatsPricesJob (queue offer-generation) does the rewriting. It flushes the resolver’s memo, then calls:

Terminal window
offers:recalculate-prices --apply --force \
--product={id} --departure-date={Y-m-d} \
--status=draft --status=approved_by_supplier --status=active

Drafts are repriced too: a draft activated later must not carry a price from a campaign that has since ended. Afterwards the job writes last_seats_departures.applied_discount_percent — the effective percentage at 2 pax, which is the basis the persisted offers are stored on — so the sweep can tell a departure whose window opened or closed from one nobody has touched.

The job is dispatched when:

  • the Arkana screen is saved (one dispatch per touched departure date, after the transaction commits — including dates that were removed, which must lose the discount immediately)
  • a booking consumes seats (DB::afterCommit)
  • a cancelled booking releases seats
  • the scheduled sweep finds a mismatch

Scheduled every 15 minutes (withoutOverlapping, onOneServer) in routes/console.php. It compares each departure’s applied_discount_percent against what the resolver says should apply now (tolerance 0.01 percentage points) and queues a sync job for every mismatch. --dry-run lists them without queueing.

This sweep exists because two things change the answer with nobody touching anything: a schedule that opens or closes, and a quota that runs out.

Missing the “campaign ended” half is not cosmetic. When a campaign ends without its offers being re-priced, OfferActivationGuard::saleShortfall() recomputes an undiscounted cost against a still-discounted stored price and refuses every checkout of that departure with a 409. Re-persisting is what closes that window.

Source: backend/app/Jobs/SyncLastSeatsPricesJob.php, backend/app/Console/Commands/SyncLastSeatsPricesCommand.php

Seats are consumed at booking finalization (post-payment), inside the same locked transaction as allotment:

  1. CheckoutSessionService freezes a last_seats snapshot into the session — departure id, percentage, title, seats covered and the land discount amount — so the booking keeps the deal the customer saw even if the campaign is edited or ends mid-checkout.
  2. BookingFinalizationService calls CampaignSeatService::consume() right after consumeAllotment(), still inside the transaction.
  3. consume() locks the departure row (lockForUpdate()) so two bookings finishing at once cannot both take the last seat, then writes one LastSeatsSeatConsumption row (updateOrCreate, so re-finalization is idempotent).

If the quota ran short between payment and finalization, the booking keeps the price it paid and only the remaining seats are recorded, with a [LastSeats] Campaign quota ran short warning. The seat exists physically — the quota is a commercial subset of the allotment checked separately — so a shortfall is a commercial matter, not a reason to fail a paid booking.

When the quota was completely spent by the time finalization ran, consume() returns before writing anything: no consumption row, no re-sync dispatch. The customer keeps the discounted price they paid, but nothing records that they had it — so the supplier’s Land cost shows the full undiscounted amount to pay for that booking.

CampaignSeatService::release() deletes the booking’s consumption rows and re-syncs the departure. It fires from the Booking model’s updated hook when the status becomes cancelled, alongside the allotment release.

The percentage and the seat count are frozen on the consumption row, so editing or ending a campaign never moves them. The title is not frozen — LastSeatsSeatConsumption has no title column, so the supplier view reads it from the live period — and neither is the money: see Supplier land cost.

LastSeatsSection::isVisible() — the is_active switch wins; each of starts_at / ends_at applies only when set.

The operator types the schedule in the admin’s own timezone (Filament’s panel timezone, app.user_default_timezone, default Europe/Madrid), and it is stored as a UTC instant like every other datetime. isVisible() compares instants, so the Carbon::now(self::SCHEDULE_TIMEZONE) it builds has no effect on the outcome. LastSeatsSection::SCHEDULE_TIMEZONE is used only inside the admin page — the status line and dateOrNull()’s parsing of the period dates, which are date-only columns — never in deciding when the section goes live.

Visibility is a pricing concern, not only a rendering one: while the section is not visible, its campaign departures are back at their normal price everywhere.

CardVisibility backs both the admin’s status line and the API’s card filter, so the Arkana preview and the live page apply the same rules. A card is dropped when:

Condition Admin status
No period with ticked offers Hidden on the web: add a discount period with offers.
No “from” price — every ticked departure is sold out, none has a bookable 2A offer with flights, or (in Arkana) none can be re-quoted because its land price comes back null: a blackout, a missing room-type price, or an FX gap Hidden on the web: no campaign departures with seats left.
No comparison price (unsaved form only — the column is not nullable) Hidden on the web: no comparison price yet.
“From” price ≥ comparison price Hidden on the web: the 'from' price is not below the comparison price.

The percentage printed on the card is derived, never stored: floor((comparison − from) / comparison × 100), floored so a saving is never rounded up, and null below 1%. A saving under 1% therefore drops the card while CardVisibility::status() still reports it as visible — the one case in which the two disagree while reading the same price.

They also disagree whenever their prices differ, which is by design: Arkana’s status line re-quotes from supplier rates while the web reads the persisted offer price (see below). Until a sync drains, a card can read “Visible on the web” in Arkana and not be there yet — or the reverse.

With no visible card the whole section behaves as switched off: the API answers data: null, the navbar link and the home band disappear, the page redirects to /{market}/404, and the URL is dropped from the sitemap (SitemapUrlManifest).

The card’s “from” price is not ProductByMarket::getLeadingPrice(), which mins marketing_price_per_pax over every bookable offer — ignoring the seat quota, ignoring which dates are on campaign, accepting any room type.

Two paths compute it, deliberately differently:

  • API / live page (LastSeatsSectionQuery::fromPrice()) — the cheapest marketing_price_per_pax among bookable 2A offers with flights, on the product’s campaign departures that still have seats. Reading the persisted price is what guarantees the page cannot advertise a number checkout would not honour.
  • Arkana and preview (FromPriceCalculator) — re-quoted from supplier rates: land is recalculated, the percentage being edited is applied, and the offer’s own margin, basis and rounding turn it back into a per-person price. This is why an unsaved percentage previews correctly, and why the number the admin sees is the number the web will show once the sync has run.

Source: backend/app/Services/LastSeats/CardVisibility.php, LastSeatsSectionQuery.php, FromPriceCalculator.php

The Arkana Preview header action opens the public page with whatever is in the form, saved or not. The state travels in an encrypted URL token (Crypt::encryptString with the app key) valid for 30 minutes — the payload names products and prices that are not public yet, and a plain encoded blob would be readable and editable by anyone the link reaches.

The preview deliberately bypasses the switch and the schedule (the point is to look at a campaign before publishing it); every other rule still applies, including seats already sold. The page renders noindex, nofollow, and the frontend never caches a preview response — an invalid or expired token is treated as no preview at all, never as an error.

Source: backend/app/Services/LastSeats/LastSeatsPreviewToken.php, LastSeatsSectionQuery::fromFormState()

Suppliers see no prices anywhere else in Arkana. The Land cost section on Trips to Deliver is the one exception, and it exists for this campaign: a departure sold at a discount is a departure the supplier agreed to charge less for, so they must be able to check the figure.

  • The discount row appears only on bookings that carry a last_seats_seat_consumptions row. The section around it is gated on the supplier simply having priced services on the tour — see Trips to Deliver → Land cost.
  • Amounts are in the supplier’s own contract currency — the figure they invoice, not the euros Volāre sells in.
  • On a tour operated by two DMCs, each sees only their own services, and the discount is applied to their own subtotal, so nobody is shown or charged a discount on a colleague’s work.
  • The percentage and seat count come from the frozen consumption row. The title does not — there is no title column, so it is read from the live period and will change if the campaign is renamed. The amounts are re-derived from the supplier’s current rate prices; the frozen land_discount_amount is stored in the market currency and is never read back by this screen.
  • When the quota only covered part of the party, the amount is smaller than the headline percentage suggests, and the helper text says “Applies to N of M travellers.”
  • The section footnote states that the extra-night supplement is not discounted.

SupplierLandCost resolves which packages the departure includes through PackageSlotResolver — the same resolver calculateLandPrice() uses — so the customer price and the supplier breakdown can never disagree about what a departure contains.

Source: backend/app/Services/LastSeats/SupplierLandCost.php, backend/app/Filament/Resources/TripsToDeliver/Schemas/TripToDeliverInfolist.php

Surface Route / file Behaviour when the section is not live
Navbar link frontend/src/shared/ui/Navbar.astro Link absent
Home band frontend/src/components/lastSeats/LastSeatsBand.astro Band absent
Page /{market}/ultimas-plazas (file route [market]/last-seats.astro) Redirect to /{market}/404
Sitemap SitemapUrlManifest URL not listed

All four key off the same data: null from GET /api/{market}/{lang}/cms/last-seats, so they cannot disagree about whether the section is live.

The navbar carries one extra gate the others do not: it fetches and appends the link only when the caller has not supplied its own leftLinks. A page or market that passes explicit leftLinks gets no Last Seats link even while the section is live. The fetch joins the Promise.all batch the navbar already runs on every page, so it adds no serial latency; the label carries the headline percentage (Últimas plazas -40%) when one is configured.

  • The discount applies to the supplier land price only; flights, insurance and the extra-night supplement are unchanged
  • On package tours the percentage comes off the supplier’s price in the supplier’s currency, before FX conversion; the dormant itemized branch applies it after conversion
  • A departure date belongs to at most one discount period of a product (enforced by a DB unique index)
  • The seat quota counts travellers, not bookings, and is per departure date, not per period
  • A party larger than the remaining seats gets one blended per-person price: campaign price for the seats it takes, normal price for the rest
  • A campaign never makes a date unsellable — a spent quota returns the departure to its normal price while its siblings stay on campaign
  • Seats are consumed post-payment inside the finalization transaction and released on cancellation
  • The percentage, seat count and land discount amount are written to the consumption row at finalization; the campaign title is not, and the supplier view recomputes the money rather than reading the stored amount
  • The discount only exists while the section is visible (the is_active switch plus an optional start/end schedule)
  • With no visible card, the section behaves as switched off everywhere, including the sitemap
  • The campaign only reaches customers once the persisted offers rows are rewritten; a campaign that ends without a re-price makes the activation guard refuse checkout with a 409
  • Only admins can open the Arkana screen (LastSeats::canAccess())
  • The percentage is capped at 50% and the headline “up to X%” is hand-typed — the form warns, but does not refuse, when it promises more than any card delivers
Terminal window
cd backend && ./vendor/bin/sail artisan test --compact tests/Feature/LastSeats
./vendor/bin/sail artisan test --compact --filter=LastSeatsApiTest
./vendor/bin/sail artisan test --compact --filter=LastSeatsPageTest
File Covers
tests/Feature/LastSeats/CampaignDiscountTest.php Land-price discount, supplement exclusion, quota pro-rating, “never returns null”, schedule windows, market/date scoping
tests/Feature/LastSeats/CampaignSeatQuotaTest.php Blended pricing, checkout snapshot, consumption, shortfall, release on cancellation, frozen discount
tests/Feature/LastSeats/SyncLastSeatsPricesTest.php Job writes/reverts the campaign price, records applied_discount_percent, scopes to one date; sweep queueing and --dry-run
tests/Feature/LastSeats/SupplierLandCostTest.php Supplier breakdown, two-DMC split, partial-quota discount, frozen terms, supplier with no services
tests/Feature/Api/LastSeatsApiTest.php Endpoint payload and every data: null case
tests/Feature/Filament/Pages/LastSeatsPageTest.php Admin access gate, persistence, re-pricing dispatch on save and on removal, one section per market, offer listing, validation
  • OfferscalculateLandPrice(), cost slots, offers:recalculate-prices
  • Allotment — the inventory gate the quota sits beside
  • Trips to Deliver — the supplier-facing breakdown
  • CMS Content API — the public endpoint
  • Arkana → Products — the admin screen
  • Booking Lifecycle — where consumption and release happen
  • Source: backend/app/Services/LastSeats/
  • Source: backend/app/Filament/Pages/LastSeats.php
  • Source: frontend/src/pages/[market]/last-seats.astro