Loading content...
Availability management is the heart of any booking system—and in hotel systems, it's uniquely challenging. Unlike traditional inventory where items exist or don't, hotel rooms exist in a temporal dimension. Room 301 might be available on March 15th, booked on March 16th, available again on March 17th, and blocked for maintenance on March 18th.
This temporal complexity, combined with high concurrency (many users searching and booking simultaneously), strategic overbooking requirements, and multi-channel distribution creates a fascinating engineering challenge that separates amateur designs from production-ready systems.
By the end of this page, you will understand how to design an availability management system that handles temporal inventory, supports overbooking strategies, maintains consistency under concurrent bookings, and integrates with multiple distribution channels. These skills are essential for any production hotel booking system.
The fundamental question: How do we model availability? There are two primary approaches:
Approach 1: Derived from Reservations (Query-Time Calculation)
Approach 2: Explicit Availability Records (Pre-Computed)
Industry Standard: Most production systems use Approach 2 with periodic reconciliation. The read-heavy nature of hotel searches (100:1 search-to-book ratio) makes pre-computed availability essential for performance.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
/** * DailyInventory - tracks room availability for a single date * One record per hotel + room type + date combination */class DailyInventory { private readonly id: DailyInventoryId; private readonly hotelId: HotelId; private readonly roomTypeId: RoomTypeId; private readonly date: LocalDate; // Physical inventory private readonly physicalRooms: number; // Total rooms of this type private outOfOrderRooms: number; // Blocked for maintenance // Calculated availability private confirmedReservations: number; // Confirmed bookings private pendingReservations: number; // Unconfirmed bookings (held) // Overbooking management private overbookingAllowance: number; // Extra rooms allowed to sell private sellLimit: number; // Manually set sales cap (can be < physical) // Channel allocation (optional advanced feature) private channelAllocations: Map<BookingChannel, number>; // Computed properties get totalInventory(): number { return this.physicalRooms - this.outOfOrderRooms; } get effectiveSellLimit(): number { // Sell limit including overbooking allowance, capped by total return Math.min( this.sellLimit + this.overbookingAllowance, this.physicalRooms + this.overbookingAllowance ); } get totalBooked(): number { return this.confirmedReservations + this.pendingReservations; } get availableToSell(): number { return Math.max(0, this.effectiveSellLimit - this.totalBooked); } get isSoldOut(): boolean { return this.availableToSell === 0; } get isOverbooked(): boolean { return this.confirmedReservations > this.totalInventory; } get overbookingLevel(): number { // Positive if overbooked, negative if under-booked return this.confirmedReservations - this.totalInventory; } // ============ Modification Methods ============ /** * Reserve inventory for a new booking * @throws InsufficientInventoryError if no availability */ reserve(count: number = 1, status: 'CONFIRMED' | 'PENDING' = 'CONFIRMED'): void { if (count > this.availableToSell) { throw new InsufficientInventoryError( `Cannot reserve ${count} rooms on ${this.date}. Available: ${this.availableToSell}` ); } if (status === 'CONFIRMED') { this.confirmedReservations += count; } else { this.pendingReservations += count; } } /** * Release inventory when booking is cancelled or modified */ release(count: number = 1, status: 'CONFIRMED' | 'PENDING' = 'CONFIRMED'): void { if (status === 'CONFIRMED') { this.confirmedReservations = Math.max(0, this.confirmedReservations - count); } else { this.pendingReservations = Math.max(0, this.pendingReservations - count); } } /** * Convert pending to confirmed (on payment) */ confirmPending(count: number = 1): void { if (count > this.pendingReservations) { throw new InvalidInventoryOperationError( `Cannot confirm ${count} pending. Only ${this.pendingReservations} pending.` ); } this.pendingReservations -= count; this.confirmedReservations += count; } /** * Block rooms for maintenance/out-of-order */ blockRooms(count: number, reason: string): void { const newOutOfOrder = this.outOfOrderRooms + count; if (newOutOfOrder > this.physicalRooms) { throw new InvalidInventoryOperationError( `Cannot block ${count} rooms. Would exceed physical inventory.` ); } this.outOfOrderRooms = newOutOfOrder; // Might need to adjust sell limit if blocking reduces available } /** * Update overbooking allowance (revenue management) */ setOverbookingAllowance(allowance: number): void { if (allowance < 0) { throw new InvalidInventoryOperationError( 'Overbooking allowance cannot be negative' ); } // Cap at a percentage of physical inventory (e.g., max 10%) const maxAllowance = Math.floor(this.physicalRooms * 0.10); this.overbookingAllowance = Math.min(allowance, maxAllowance); } /** * Set manual sell limit (close out) */ setSellLimit(limit: number): void { if (limit < 0) { throw new InvalidInventoryOperationError('Sell limit cannot be negative'); } if (limit < this.confirmedReservations) { // Can't reduce below already sold throw new InvalidInventoryOperationError( `Cannot set limit to ${limit}. Already have ${this.confirmedReservations} confirmed.` ); } this.sellLimit = limit; }}Notice we track pending and confirmed separately. Pending reservations (awaiting payment) shouldn't block inventory indefinitely—they should auto-expire. However, during the payment window, they must be counted or we risk overselling. Tracking separately allows for nuanced policies.
A critical requirement: a room must be available for ALL nights of a stay, not just some. This is the continuous availability problem.
Example Scenario:
Guest wants March 15-18 (3 nights). We have 10 Deluxe rooms:
Result: Cannot accommodate even though 2 of 3 nights have availability.
The naive approach (check each night independently) fails. We need to find the minimum availability across all nights of the stay.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
/** * AvailabilityService - handles availability queries and reservations */class AvailabilityService { private readonly inventoryRepository: DailyInventoryRepository; private readonly lockManager: DistributedLockManager; /** * Check availability for a stay * Returns the maximum number of rooms bookable for the entire stay */ async checkAvailability( hotelId: HotelId, roomTypeId: RoomTypeId, checkIn: LocalDate, checkOut: LocalDate ): Promise<AvailabilityResult> { // Get inventory records for all nights const nights = this.getStayNights(checkIn, checkOut); const inventoryRecords = await this.inventoryRepository.findByDates( hotelId, roomTypeId, nights ); // Validate we have records for all nights if (inventoryRecords.length !== nights.length) { const missingDates = this.findMissingDates(nights, inventoryRecords); throw new InventoryNotConfiguredError( `No inventory configured for dates: ${missingDates.join(', ')}` ); } // Find minimum availability across all nights const minAvailable = Math.min( ...inventoryRecords.map(inv => inv.availableToSell) ); // Find the constraining night (for diagnostics) const constrainingNight = inventoryRecords.find( inv => inv.availableToSell === minAvailable ); return new AvailabilityResult({ hotelId, roomTypeId, checkIn, checkOut, nights: nights.length, availableRooms: minAvailable, isSoldOut: minAvailable === 0, constrainingDate: constrainingNight?.date, dailyBreakdown: inventoryRecords.map(inv => ({ date: inv.date, available: inv.availableToSell, totalInventory: inv.totalInventory, })), }); } /** * Reserve inventory for a booking (atomic across all nights) */ async reserveInventory( hotelId: HotelId, roomTypeId: RoomTypeId, checkIn: LocalDate, checkOut: LocalDate, roomCount: number, reservationId: ReservationId ): Promise<void> { const nights = this.getStayNights(checkIn, checkOut); const lockKey = `inventory:${hotelId}:${roomTypeId}`; // Acquire distributed lock to prevent race conditions await this.lockManager.withLock(lockKey, async () => { // Re-check availability with lock held const availability = await this.checkAvailability( hotelId, roomTypeId, checkIn, checkOut ); if (availability.availableRooms < roomCount) { throw new InsufficientInventoryError( `Requested ${roomCount} rooms, only ${availability.availableRooms} available` ); } // Reserve on all nights atomically const inventoryRecords = await this.inventoryRepository.findByDates( hotelId, roomTypeId, nights ); for (const inventory of inventoryRecords) { inventory.reserve(roomCount, 'CONFIRMED'); } // Single transaction to update all await this.inventoryRepository.saveAll(inventoryRecords); // Log for reconciliation await this.logInventoryChange({ operation: 'RESERVE', reservationId, hotelId, roomTypeId, dates: nights, roomCount, timestamp: Instant.now(), }); }); } /** * Release inventory when booking is cancelled */ async releaseInventory( hotelId: HotelId, roomTypeId: RoomTypeId, checkIn: LocalDate, checkOut: LocalDate, roomCount: number, reservationId: ReservationId ): Promise<void> { const nights = this.getStayNights(checkIn, checkOut); // No lock needed for release - we're increasing availability // However, we do it in transaction for consistency const inventoryRecords = await this.inventoryRepository.findByDates( hotelId, roomTypeId, nights ); for (const inventory of inventoryRecords) { inventory.release(roomCount, 'CONFIRMED'); } await this.inventoryRepository.saveAll(inventoryRecords); await this.logInventoryChange({ operation: 'RELEASE', reservationId, hotelId, roomTypeId, dates: nights, roomCount, timestamp: Instant.now(), }); } /** * Handle modification - adjust inventory for date changes */ async adjustInventoryForModification( reservationId: ReservationId, originalDates: DateRange, newDates: DateRange, hotelId: HotelId, roomTypeId: RoomTypeId, roomCount: number ): Promise<void> { // Calculate date differences const originalNights = new Set( this.getStayNights(originalDates.start, originalDates.end) .map(d => d.toString()) ); const newNights = new Set( this.getStayNights(newDates.start, newDates.end) .map(d => d.toString()) ); const toRelease: LocalDate[] = []; const toReserve: LocalDate[] = []; // Nights being released (in original but not in new) for (const night of originalNights) { if (!newNights.has(night)) { toRelease.push(LocalDate.parse(night)); } } // Nights being added (in new but not in original) for (const night of newNights) { if (!originalNights.has(night)) { toReserve.push(LocalDate.parse(night)); } } const lockKey = `inventory:${hotelId}:${roomTypeId}`; await this.lockManager.withLock(lockKey, async () => { // First check availability for new nights if (toReserve.length > 0) { const availability = await this.checkAvailabilityForNights( hotelId, roomTypeId, toReserve ); if (availability.minAvailable < roomCount) { throw new InsufficientInventoryError( `Cannot extend to new dates. Required: ${roomCount}, available: ${availability.minAvailable}` ); } } // Release old nights if (toRelease.length > 0) { const releaseRecords = await this.inventoryRepository.findByDates( hotelId, roomTypeId, toRelease ); for (const inv of releaseRecords) { inv.release(roomCount, 'CONFIRMED'); } await this.inventoryRepository.saveAll(releaseRecords); } // Reserve new nights if (toReserve.length > 0) { const reserveRecords = await this.inventoryRepository.findByDates( hotelId, roomTypeId, toReserve ); for (const inv of reserveRecords) { inv.reserve(roomCount, 'CONFIRMED'); } await this.inventoryRepository.saveAll(reserveRecords); } }); } private getStayNights(checkIn: LocalDate, checkOut: LocalDate): LocalDate[] { const nights: LocalDate[] = []; let current = checkIn; while (current.isBefore(checkOut)) { nights.push(current); current = current.plusDays(1); } return nights; }}A stay from March 15 to March 18 is 3 nights (15th, 16th, 17th), not 4 nights. The checkout date is excluded from inventory consumption—the guest leaves that morning. Getting this wrong is a common bug that leads to over-blocking inventory.
Overbooking is a deliberate strategy where hotels accept more reservations than physical capacity, anticipating cancellations and no-shows. When done well, it maximizes revenue; when done poorly, it damages reputation through "walks" (relocating guests to other hotels).
The Math:
If a hotel has:
Expected rooms freed: 11 (8 + 3)
So selling 108-110 rooms may maximize revenue while keeping walk risk low.
Design Considerations:
| Factor | Impact on Overbooking Level | Notes |
|---|---|---|
| Historical no-show rate | Higher rate → more overbooking | Analyze by room type, season, rate plan |
| Cancellation rate | Higher rate → more overbooking | Consider lead time distribution |
| Rate plan mix | Non-refundable → lower no-shows | Prepaid guests rarely no-show |
| Group vs. transient | Groups more reliable | But group cancellations are larger impact |
| Walk cost | Higher cost → less overbooking | Includes comp rooms, reputation damage |
| Demand level | High demand → less overbooking needed | Sold-out nights need less buffer |
| Season | Peak season → riskier walks | Walk on New Year's Eve is catastrophic |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
/** * OverbookingEngine - calculates optimal overbooking levels */class OverbookingEngine { private readonly historicalAnalyzer: HistoricalDataAnalyzer; private readonly costCalculator: WalkCostCalculator; /** * Calculate recommended overbooking allowance for a date */ calculateRecommendedOverbooking( hotelId: HotelId, roomTypeId: RoomTypeId, date: LocalDate ): OverbookingRecommendation { // Get historical rates for similar periods const historicalRates = this.historicalAnalyzer.getRates( hotelId, roomTypeId, date ); const expectedNoShows = historicalRates.noShowRate; const expectedCancellations = historicalRates.lateCancellationRate; const totalExpectedFreed = expectedNoShows + expectedCancellations; // Get inventory size const inventory = this.getInventorySize(hotelId, roomTypeId); // Calculate raw overbooking count const rawOverbooking = Math.floor(inventory * totalExpectedFreed); // Calculate walk risk at different levels const walkRisk = this.calculateWalkRisk( inventory, rawOverbooking, historicalRates.standardDeviation ); // Calculate expected walk cost const expectedWalkCost = this.costCalculator.calculate( hotelId, date, walkRisk.expectedWalks ); // Calculate revenue gain from overbooking const avgNightlyRate = this.getAverageRate(hotelId, roomTypeId, date); const expectedRevenueGain = avgNightlyRate.multiply(rawOverbooking) .multiply(1 - walkRisk.walkProbability); // Optimize: find overbooking level that maximizes (revenue gain - walk cost) const optimalLevel = this.optimizeOverbookingLevel( inventory, historicalRates, avgNightlyRate, this.costCalculator.getWalkCost(hotelId, date) ); return new OverbookingRecommendation({ date, roomTypeId, physicalInventory: inventory, historicalNoShowRate: expectedNoShows, historicalLateCancellationRate: expectedCancellations, recommendedOverbooking: optimalLevel.count, walkRiskPercentage: optimalLevel.walkRisk, expectedRevenueImpact: optimalLevel.netRevenueImpact, confidence: historicalRates.sampleSize > 100 ? 'HIGH' : 'MEDIUM', }); } private optimizeOverbookingLevel( inventory: number, rates: HistoricalRates, avgRate: Money, walkCost: Money ): OptimalLevel { let bestLevel = 0; let bestNetRevenue = Money.zero(); let bestWalkRisk = 0; // Try each level from 0 to max (e.g., 15% of inventory) const maxOverbooking = Math.floor(inventory * 0.15); for (let level = 0; level <= maxOverbooking; level++) { // Expected rooms freed based on historical rates const expectedFreed = inventory * (rates.noShowRate + rates.lateCancellationRate); // If we overbook by 'level', walks occur when freed < level // Use normal distribution to calculate probability const walkProbability = this.calculateWalkProbability( level, expectedFreed, rates.standardDeviation * inventory ); const expectedWalks = level * walkProbability; const revenueGain = avgRate.multiply(level * (1 - walkProbability)); const walkCosts = walkCost.multiply(expectedWalks); const netRevenue = revenueGain.subtract(walkCosts); if (netRevenue.isGreaterThan(bestNetRevenue)) { bestLevel = level; bestNetRevenue = netRevenue; bestWalkRisk = walkProbability; } } return { count: bestLevel, walkRisk: bestWalkRisk, netRevenueImpact: bestNetRevenue, }; } private calculateWalkProbability( overbooking: number, expectedFreed: number, stdDev: number ): number { // Probability that actualFreed < overbooking // Using normal distribution CDF const z = (overbooking - expectedFreed) / stdDev; return this.normalCDF(z); } private normalCDF(z: number): number { // Approximation of standard normal CDF const a1 = 0.254829592; const a2 = -0.284496736; const a3 = 1.421413741; const a4 = -1.453152027; const a5 = 1.061405429; const p = 0.3275911; const sign = z < 0 ? -1 : 1; z = Math.abs(z) / Math.sqrt(2); const t = 1.0 / (1.0 + p * z); const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-z * z); return 0.5 * (1.0 + sign * y); }} /** * WalkCostCalculator - determines cost of walking a guest */class WalkCostCalculator { calculate(hotelId: HotelId, date: LocalDate, expectedWalks: number): Money { // Components of walk cost: // 1. Room at alternate hotel (often at premium) // 2. Transportation to alternate // 3. Compensation (points, future discount) // 4. Reputation damage (harder to quantify) const baseWalkCost = Money.of(300, 'USD'); // Varies by market // Higher cost during peak periods const seasonMultiplier = this.getSeasonMultiplier(date); return baseWalkCost.multiply(seasonMultiplier).multiply(expectedWalks); } getWalkCost(hotelId: HotelId, date: LocalDate): Money { return Money.of(300, 'USD'); // Simplified; would be configurable } private getSeasonMultiplier(date: LocalDate): number { // Higher multiplier during holidays, events const month = date.monthValue; if (month === 12 || month === 7 || month === 8) { return 2.0; // Peak seasons } return 1.0; }}In practice, overbooking is managed by Revenue Management systems (like IDeaS or Duetto) that run sophisticated ML models. Your booking system should expose the overbooking allowance as configurable per room type per date, allowing these systems to update it daily based on their predictions.
When hundreds of users search and book simultaneously, race conditions can lead to overselling. Consider this scenario:
The Race Condition:
Concurrency Strategies:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
/** * Pattern 1: Optimistic Locking with Version Column */class DailyInventoryWithVersion { private readonly id: DailyInventoryId; private version: number; // Incremented on each update // ... other fields incrementVersion(): void { this.version += 1; }} // Repository with optimistic lockingclass OptimisticInventoryRepository { async save(inventory: DailyInventoryWithVersion): Promise<void> { const originalVersion = inventory.version; inventory.incrementVersion(); const result = await this.db.update( 'daily_inventory', { ...inventory.toDbRow(), version: inventory.version }, // WHERE clause includes version check { id: inventory.id, version: originalVersion // Must match original } ); if (result.rowsAffected === 0) { throw new ConcurrentModificationError( 'Inventory was modified by another process. Please retry.' ); } }} /** * Pattern 2: Distributed Locking (for multi-instance deployments) */interface DistributedLockManager { acquireLock(key: string, ttlMs: number): Promise<LockHandle>; releaseLock(handle: LockHandle): Promise<void>; withLock<T>(key: string, fn: () => Promise<T>): Promise<T>;} // Redis-based implementationclass RedisLockManager implements DistributedLockManager { private readonly redis: Redis; async acquireLock(key: string, ttlMs: number): Promise<LockHandle> { const lockKey = `lock:${key}`; const lockValue = crypto.randomUUID(); // SET NX (only if not exists) with expiration const acquired = await this.redis.set( lockKey, lockValue, 'NX', 'PX', ttlMs ); if (!acquired) { throw new LockNotAcquiredError(`Could not acquire lock for ${key}`); } return { key: lockKey, value: lockValue }; } async releaseLock(handle: LockHandle): Promise<void> { // Lua script for atomic check-and-delete const script = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end `; await this.redis.eval(script, 1, handle.key, handle.value); } async withLock<T>(key: string, fn: () => Promise<T>): Promise<T> { const handle = await this.acquireLock(key, 10000); // 10 second TTL try { return await fn(); } finally { await this.releaseLock(handle); } }} /** * Pattern 3: Atomic Database Operations * Using database capabilities for atomic updates */class AtomicInventoryRepository { /** * Reserve inventory atomically - single SQL statement * Returns true if reserved, false if insufficient */ async atomicReserve( hotelId: HotelId, roomTypeId: RoomTypeId, dates: LocalDate[], count: number ): Promise<boolean> { // This uses a CTE to check all dates have availability // and updates them atomically const result = await this.db.execute(` WITH availability_check AS ( SELECT date, (sell_limit + overbooking_allowance - confirmed - pending) as available FROM daily_inventory WHERE hotel_id = $1 AND room_type_id = $2 AND date = ANY($3) ), can_book AS ( SELECT MIN(available) >= $4 as bookable FROM availability_check ) UPDATE daily_inventory SET confirmed = confirmed + $4, updated_at = NOW() WHERE hotel_id = $1 AND room_type_id = $2 AND date = ANY($3) AND (SELECT bookable FROM can_book) = true RETURNING date `, [hotelId, roomTypeId, dates, count]); // If we updated all expected rows, booking succeeded return result.rowCount === dates.length; }} /** * Pattern 4: Reservation with Expiring Hold * Soft-lock inventory with auto-expiration */class InventoryHoldService { private readonly redis: Redis; private readonly holdTtlSeconds: number = 900; // 15 minutes /** * Place a temporary hold on inventory */ async placeHold( reservationId: ReservationId, hotelId: HotelId, roomTypeId: RoomTypeId, dates: LocalDate[], count: number ): Promise<InventoryHold> { // Store hold in Redis with expiration const holdId = crypto.randomUUID(); const holdData = { reservationId, hotelId, roomTypeId, dates: dates.map(d => d.toString()), count, createdAt: Date.now(), }; await this.redis.setex( `hold:${holdId}`, this.holdTtlSeconds, JSON.stringify(holdData) ); // Also update pending count in inventory // (pending will auto-decrement via background job when hold expires) await this.inventoryService.reservePending( hotelId, roomTypeId, dates, count ); return new InventoryHold({ holdId, expiresAt: Instant.now().plusSeconds(this.holdTtlSeconds), ...holdData, }); } /** * Convert hold to confirmed reservation */ async confirmHold(holdId: string): Promise<void> { const holdData = await this.redis.get(`hold:${holdId}`); if (!holdData) { throw new HoldExpiredError('Reservation hold has expired'); } const hold = JSON.parse(holdData); // Convert pending to confirmed await this.inventoryService.confirmPending( hold.hotelId, hold.roomTypeId, hold.dates.map(d => LocalDate.parse(d)), hold.count ); // Delete the hold await this.redis.del(`hold:${holdId}`); }}Use atomic database operations for single-database deployments—they're simple and effective. Add distributed locking (Redis/Redlock) for multi-instance deployments. Implement reservation holds with expiration to improve conversion rates (guests can browse without committing) while preventing indefinite inventory blocks.
Hotels sell inventory through multiple channels: direct website, OTAs (Booking.com, Expedia), GDS (Amadeus, Sabre), and more. Channel management adds complexity to availability tracking:
Allocation Strategies:
Challenges:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
/** * ChannelManager - coordinates inventory across distribution channels */class ChannelManager { private readonly channelConnectors: Map<BookingChannel, ChannelConnector>; private readonly eventBus: EventBus; constructor(connectors: ChannelConnector[]) { this.channelConnectors = new Map( connectors.map(c => [c.channel, c]) ); // Subscribe to inventory changes this.eventBus.subscribe( InventoryChangedEvent, this.handleInventoryChange.bind(this) ); } /** * Push availability update to all connected channels */ async handleInventoryChange(event: InventoryChangedEvent): Promise<void> { const { hotelId, roomTypeId, dates, newAvailability } = event; // Build ARI (Availability, Rates, Inventory) update const ariUpdate = new ARIUpdate({ hotelId, roomTypeId, updates: dates.map(date => ({ date, availability: newAvailability.get(date.toString()), rateUpdate: null, // Rates updated separately })), }); // Fan out to all channels concurrently const updatePromises = Array.from(this.channelConnectors.values()) .filter(c => c.isActive) .map(connector => this.pushUpdate(connector, ariUpdate)); // Wait for all, but don't fail entire operation if one channel fails const results = await Promise.allSettled(updatePromises); // Log failures for alerting results.forEach((result, index) => { if (result.status === 'rejected') { this.logChannelFailure( Array.from(this.channelConnectors.keys())[index], result.reason ); } }); } private async pushUpdate( connector: ChannelConnector, update: ARIUpdate ): Promise<void> { try { await connector.pushARI(update); } catch (error) { // Implement retry logic with exponential backoff await this.retryWithBackoff( () => connector.pushARI(update), 3, // max retries 1000 // initial delay ms ); } } /** * Handle booking notification from an OTA */ async handleOTABooking( channel: BookingChannel, otaReservation: OTAReservationData ): Promise<Reservation> { // Translate OTA format to our domain const reservationData = this.translateFromOTA(channel, otaReservation); // Create reservation in our system const reservation = await this.reservationService.createFromChannel( reservationData, channel ); // Acknowledge to OTA const connector = this.channelConnectors.get(channel); await connector.acknowledgeBooking( otaReservation.otaConfirmationNumber, reservation.confirmationNumber ); // Note: Inventory was already decremented when we created reservation // Channel updates will propagate via the event we raised return reservation; } /** * Handle cancellation notification from OTA */ async handleOTACancellation( channel: BookingChannel, otaConfirmationNumber: string ): Promise<void> { // Find our reservation by external reference const reservation = await this.reservationRepository.findByExternalRef( channel, otaConfirmationNumber ); if (!reservation) { throw new ReservationNotFoundError( `No reservation found for OTA ref: ${otaConfirmationNumber}` ); } // Process cancellation await this.reservationService.cancelFromChannel( reservation.id, 'Cancelled via OTA' ); // Acknowledge const connector = this.channelConnectors.get(channel); await connector.acknowledgeCancellation(otaConfirmationNumber); }} /** * Interface for OTA/Channel API connectors */interface ChannelConnector { channel: BookingChannel; isActive: boolean; // Push updates to channel pushARI(update: ARIUpdate): Promise<void>; // Fetch reservations from channel (for reconciliation) fetchReservations(since: Instant): Promise<OTAReservationData[]>; // Acknowledgments acknowledgeBooking(otaRef: string, ourRef: ConfirmationNumber): Promise<void>; acknowledgeCancellation(otaRef: string): Promise<void>;} /** * ARI Update structure (industry standard) */interface ARIUpdate { hotelId: HotelId; roomTypeId: RoomTypeId; updates: DateUpdate[];} interface DateUpdate { date: LocalDate; availability: number | null; // Rooms available to sell rate: Money | null; // Price for this date minimumStay: number | null; // LOS restrictions closedToArrival: boolean | null; closedToDeparture: boolean | null;}OTAs cache your availability—updates aren't instant. If you sell your last room on Booking.com at 3:00 PM, Expedia might still show it as available until 3:02 PM (or longer during their system delays). Design for this: maintain a small buffer, implement robust duplicate detection, and have an oversell resolution workflow.
With pre-computed availability and multi-channel distribution, reconciliation becomes essential. The computed availability can drift from reservation reality due to bugs, failed updates, or timing issues.
Reconciliation Strategy:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
/** * ReconciliationService - ensures inventory accuracy */class ReconciliationService { private readonly inventoryRepository: DailyInventoryRepository; private readonly reservationRepository: ReservationRepository; private readonly alertService: AlertService; /** * Full reconciliation for a date range */ async reconcile( hotelId: HotelId, startDate: LocalDate, endDate: LocalDate ): Promise<ReconciliationReport> { const discrepancies: Discrepancy[] = []; const autoFixed: AutoFix[] = []; // Get all room types for hotel const roomTypes = await this.getRoomTypes(hotelId); for (const roomType of roomTypes) { for (const date of this.eachDate(startDate, endDate)) { const discrepancy = await this.reconcileDay( hotelId, roomType.id, date ); if (discrepancy) { discrepancies.push(discrepancy); // Auto-fix if within threshold if (Math.abs(discrepancy.difference) <= 2) { const fix = await this.autoFix(discrepancy); autoFixed.push(fix); } else { // Alert for manual review await this.alertService.sendReconciliationAlert(discrepancy); } } } } return new ReconciliationReport({ hotelId, startDate, endDate, totalDaysChecked: this.dayCount(startDate, endDate) * roomTypes.length, discrepanciesFound: discrepancies.length, autoFixed: autoFixed.length, pendingReview: discrepancies.length - autoFixed.length, details: discrepancies, }); } /** * Reconcile a single day's inventory */ private async reconcileDay( hotelId: HotelId, roomTypeId: RoomTypeId, date: LocalDate ): Promise<Discrepancy | null> { // Count reservations from source of truth const reservationCount = await this.reservationRepository.countForDate( hotelId, roomTypeId, date, ['CONFIRMED', 'CHECKED_IN'] // Active statuses only ); // Get availability record const inventory = await this.inventoryRepository.findByDate( hotelId, roomTypeId, date ); if (!inventory) { return new Discrepancy({ type: 'MISSING_INVENTORY', hotelId, roomTypeId, date, expected: reservationCount, actual: 0, difference: reservationCount, }); } // Compare if (inventory.confirmedReservations !== reservationCount) { return new Discrepancy({ type: 'COUNT_MISMATCH', hotelId, roomTypeId, date, expected: reservationCount, actual: inventory.confirmedReservations, difference: reservationCount - inventory.confirmedReservations, }); } return null; // No discrepancy } /** * Auto-fix small discrepancies */ private async autoFix(discrepancy: Discrepancy): Promise<AutoFix> { const inventory = await this.inventoryRepository.findByDate( discrepancy.hotelId, discrepancy.roomTypeId, discrepancy.date ); const oldValue = inventory.confirmedReservations; inventory.confirmedReservations = discrepancy.expected; await this.inventoryRepository.save(inventory); // Log the fix for audit await this.logReconciliationFix({ discrepancy, oldValue, newValue: discrepancy.expected, fixedAt: Instant.now(), fixedBy: 'SYSTEM_RECONCILIATION', }); return new AutoFix({ discrepancy, action: 'UPDATED_INVENTORY', oldValue, newValue: discrepancy.expected, }); }} /** * Inventory change log for auditing * This table enables debugging and reconciliation */interface InventoryChangeLog { id: string; hotelId: HotelId; roomTypeId: RoomTypeId; date: LocalDate; operation: 'RESERVE' | 'RELEASE' | 'ADJUST' | 'RECONCILE'; previousValue: number; newValue: number; delta: number; reservationId: ReservationId | null; reason: string; performedBy: string; // User ID or 'SYSTEM' timestamp: Instant;}The InventoryChangeLog is essential for debugging production issues. When someone asks 'Why does March 15th show 2 rooms available when we only have 1 reservation?', you need to trace every change. Log everything: reserves, releases, manual adjustments, reconciliation fixes. Never delete logs—archive them.
We've explored the full complexity of availability management in hotel booking systems—from temporal inventory models through overbooking to multi-channel synchronization.
What's Next:
With core mechanics covered, we'll explore the Design Patterns Applied in the Hotel Booking System—seeing how Factory, State, and Template Method patterns provide structure for our complex domain logic.
You now understand the intricacies of hotel availability management—the temporal nature of inventory, overbooking strategies, concurrency challenges, and the importance of reconciliation. These concepts apply broadly to any booking system dealing with time-bounded resources.