Skip to content

Allotment

Allotment is the per-date inventory a supplier grants Volāre on each service rate (supplier_service_rates.allotment). AllotmentService resolves which rates an offer/booking consumes, checks remaining capacity, and consumes/releases units as bookings are finalized or cancelled.

  • Deciding whether an offer is bookable on the web (getOfferAvailability / bulk getOffersAvailability)
  • Booking finalization — consumeAllotment() is called inside BookingFinalizationService’s transaction
  • Admin offer preview — the availability breakdown shown per service (OfferPreviewData)

Not for date-range blackouts — that is Stop Sales (see below).

resolveServiceRates(Offer, array $session) maps an offer (plus any checkout upsell overrides in the session) to the SupplierServiceRate rows it consumes, each with the list of calendar service_dates:

  • Hotels emit one consumed date per stay night — each consecutive itinerary day at the hotel becomes departure_date + (day − 1). Checkout hotel upgrades replace the base hotel for the covered night range.
  • Activities and transfers emit one date per itinerary day they sit on; upsell selections on a day replace that day’s included/base entries. A trip-level luxury transfer maps to day 1 (the departure date).
  • Package services (when the tour has them) emit a single date equal to the offer’s departure date.

The rate selected for each service is the one whose window covers departure_date (SupplierService::getRateForDate()). Multi-night stays do NOT re-resolve per night, even if the stay spills into a neighbouring rate window — this deliberately matches the pricing assumption.

Consumption is tracked as one AllotmentConsumption row per (booking, rate, service_date). Remaining capacity for an offer is:

MIN over every (rate × service_date) of: rate.allotment − consumed_count

getOfferAvailability(Offer) returns an AllotmentAvailabilityResult DTO: available (the MIN), isAvailable, the bottleneck service name, and a per-service breakdown. Offers with no supplier tour rate or no constrained services are unlimited().

getOffersAvailability(Collection) is the bulk variant for product pages (all offers must share a tour; base services only, no upsells) — a few bulk queries instead of N per offer. Used by ProductByMarketController.

consumeAllotment(Booking, array $session) — must run inside a DB transaction (it does, in BookingFinalizationService):

  1. Locks all involved rate rows with lockForUpdate(), ordered by id (deterministic — no deadlocks), preventing concurrent overbooking
  2. Pre-checks every (rate, service_date) tuple and throws InsufficientAllotmentException before writing anything if one is already full
  3. Inserts the consumption rows (firstOrCreate, so re-finalization is idempotent)
  4. When this booking takes the last unit of any (rate, date), an AllotmentExhaustedNotification email goes to the Volāre entity address after commit (plus configured copies — see Email Copies)

releaseAllotment(Booking) deletes the booking’s consumption rows. It fires automatically from the Booking model’s updated hook whenever a booking’s status changes to cancelled (backend/app/Models/Booking.php).

Last Seats campaigns carry a second, unrelated counter. It looks like allotment and is deliberately not the same thing:

Allotment Last Seats quota
Counts Bookings (one row per booking × rate × service date) Travellers (a party of three takes three seats)
Granularity Service rate × calendar date Departure date of a product (all airports, all room types)
What it limits Whether the trip can be sold at all Whether the trip is sold at the campaign price
When exhausted The offer is unavailable The departure returns to its normal price and keeps selling
Partial fit Not possible — the booking is refused The party pays the campaign price for the seats left and the normal price for the rest
Stored on allotment_consumptions last_seats_seat_consumptions (with the discount frozen)

The quota is a commercial subset of the seats the supplier already contracted, never extra inventory — so it can never make a date unsellable.

Both are consumed in the same locked transaction in BookingFinalizationService::finalizeFromCheckoutSession(): AllotmentService::consumeAllotment() first, then CampaignSeatService::consume(), which locks its departure row with lockForUpdate() the same way. Both are released from the Booking model’s updated hook when a booking is cancelled.

The one asymmetry: a quota that ran short between payment and finalization does not fail the booking. The seat physically exists (allotment was checked separately), so the shortfall is recorded and logged rather than thrown.

Allotment Stop sale
Granularity Per service rate × calendar date Whole tour × date window
Semantics Counted capacity, consumed booking by booking Binary blackout, ignores capacity
Recovers When bookings are cancelled (release) When the window expires or is deleted
Managed as allotment integer on SupplierServiceRate StopSale rows on the tour

Both gates apply independently — an offer must have remaining allotment AND no active stop sale to be bookable.

  • Availability is always the worst (rate, date) tuple — one exhausted hotel night blocks the whole offer
  • The departure-date rate governs the whole stay; nights never split across rate windows
  • Overbooking is prevented with pessimistic locks at finalization, not just the earlier availability check
  • Cancelling a booking returns its units automatically
  • Stop Sales — tour-level date blackouts (the other bookability gate)
  • Last Seats — the campaign seat quota consumed alongside allotment
  • Booking Lifecycle — where consumption happens (finalization)
  • Suppliers — services and rates data model
  • Source: backend/app/Services/Allotment/AllotmentService.php
  • Source: backend/app/Services/Allotment/AllotmentAvailabilityResult.php
  • Source: backend/app/Models/SupplierServiceRate.php