Reservation Hold & Next-in-Line Audit
Comprehensive system-state reference: hold lifecycle, inventory locks, next-in-line logic, renewal priority enforcement, edge cases, and listing-edit guards.
Created the moment a renter opens the BookingWizard date-picker for a specific listing.
Creation checks (placeReservationHold):
- Listing must be
status = active - No
BookingNightwithstatus IN (confirmed, active)overlaps the dates - No other active HOLD_DRAFT from a different renter overlaps the dates
- Renter has no other active hold on any listing (1-hold-per-renter limit)
Refresh:
Each wizard step re-calls placeReservationHold — returns existing hold idempotently if same listing+dates.
Overlap blocking:
Any second renter attempting the same dates gets HTTP 409 "Another renter is currently checking out these dates. Try again in ~15 minutes." The 15-min TTL is enforced at read time (no cron needed for draft expiry).
Upgraded from HOLD_DRAFT when the renter submits the booking request (after contract sign + payment method capture).
Upgrade path (upgrade_to_pending=true):
Existing ReservationHold record is mutated: hold_type → 'pending', expires_at → now + 24h, booking_id stamped.
Owner actions during 24h window:
| Trigger | BookingNight.status written | Function |
|---|---|---|
| Owner approves booking (non-instant) | confirmed | lockBookingNights() |
| Instant-book: renter submits | confirmed | lockBookingNights() |
| Deposit charged (chargeDepositAndConfirm) | confirmed | lockBookingNights() |
| Booking goes active (check-in day) | active | processScheduledPayments (status mutation) |
| Booking cancelled / declined / expired | released | releaseBookingNights() |
| Payment capture fails (≤48h) | released | releaseBookingNights() via cancelBooking |
| Owner early-terminates | released | requestEarlyTermination → releaseBookingNights() |
⚠ Key guarantee:
lockBookingNights() does a pre-write conflict scan — it reads all BookingNight rows for that listing, filters status != released, and rejects if any date overlaps. This is the final hard-stop against double-booking, even if holds expire concurrently.
Next-in-line bookings NEVER create BookingNights
Bookings with booking_kind = next_in_line and statuses next_pending / next_offered_to_current_renter / next_ready_for_owner_review have zero inventory lock. No BookingNight rows, no ReservationHold conflict check at write time. Locks are only created after the owner accepts and payment authorizes (booking_kind is then reset to standard).
Stored in: Booking entity
booking_kind="next_in_line"status— see lifecycle belowoccupancy_booking_id— FK to current occupantdibs_expires_at— auto-void TTLdibs_reason— renter message to ownertotal_price = 0— no auth until activatedplatform_fee_amount = 0
Vacancy-confirmed hold: ReservationHold
hold_type="next_in_line"status="active"expires_at= now + 24h- Created only after occupancy ends (Rule 5 in processNextInLineBookings)
- Idempotent — checked before creation to prevent duplicates
- Blocks new wizard holds from the same listing
Next-in-line Booking Status Lifecycle
Queued; occupancy still active
Renewal window open; current renter notified
Vacancy confirmed; owner must accept/decline
Current renter renewed; dibs cancelled (no charge)
TTL elapsed or listing removed (no charge)
Owner accepted; booking_kind reset to standard; normal flow resumes
How it blocks duplicate dibs (submitNextInLineRequest):
- Query all active dibs for listing — check date range overlap (not just exact date match)
- Check for existing
ReservationHold.hold_type = next_in_lineon listing - Check if requesting user already has a dibs on this listing
- All three checks run before write — race window is the DB write latency (~ms)
Config: DockListing.renewal_priority_days_before_end (default: 5 days)
During the window [checkout − N days, checkout], processNextInLineBookings transitions dibs to next_offered_to_current_renter and notifies the current renter to renew.
State machine — renewal vs dibs:
Renewal detection logic (Rule 4a — improved):
Rather than relying solely on is_renewal_request=true on the occupancy booking (which may lag), the cron now queries for any booking_type = 'renewal' bookings that have status IN (confirmed, active, pending, balance_scheduled)and whose date range covers the dibs check-in date. This ensures the void fires even if the renewal booking is a separate record.
Two renters submit dibs at the same time
submitNextInLineRequest queries existing dibs (overlap check) before write. Both requests race to read, find nothing, and attempt to write. Second write will see the first record on its own subsequent read if it re-queries, BUT if both writes land simultaneously, the conflict will surface at the placeReservationHold step when the vacancy is confirmed (lockBookingNights conflict scan). Practical race window is DB write latency — safe in practice.
Current renter renews while a next-in-line dibs exists
processNextInLineBookings (runs every 15 min) detects a renewal booking overlapping the dibs check_in_date and sets status → next_voided_by_renewal. No charge ever issued — dibs bookings have total_price = 0 and no PaymentIntent.
Owner deletes/unpublishes listing with active dibs
manageListing now explicitly filters next-in-line bookings (booking_kind = next_in_line, status IN next_pending/offered/ready) and sets them to next_expired before any deactivate/delete action. Renters emailed. No refund needed (no money was ever taken).
Payment fails at capture (≤48h) while a dibs exists
Payment failure triggers cancelBooking → releaseBookingNights (nights released) → hold expired. The dibs booking remains in next_ready_for_owner_review — it is unaffected. The next processNextInLineBookings run will re-check if the slip is still vacant (it is) and the owner can still accept the dibs. No accidental double-lock occurs because nights were released.
Dibs TTL expires (dibs_expires_at passes)
processNextInLineBookings checks dibs_expires_at vs now as Rule 1. Status → next_expired. No charge, no hold was ever placed. Renter emailed. Listing immediately available for new bookings or dibs.
Owner declines next-in-line after accepting (edge)
Owner accept → booking_kind reset to standard → status pending → authorizeBookingPayment. If that auth fails, standard payment-failure flow applies. Dibs was already promoted so no separate dibs booking remains. Handled identically to a normal booking payment failure.
| Booking Status | Unlist | Deactivate | Delete |
|---|---|---|---|
| confirmed | 🚫 Blocked | 🚫 Blocked | 🚫 Blocked |
| active | 🚫 Blocked | 🚫 Blocked | 🚫 Blocked |
| balance_scheduled | 🚫 Blocked | 🚫 Blocked | 🚫 Blocked |
| pending | ✅ Preserved to deadline | ✅ Auto-cancelled + notified | ✅ Auto-cancelled + notified |
| holding | 🚫 Blocked | 🚫 Blocked | 🚫 Blocked |
| next_pending | ✅ Voided (no charge) | ✅ Voided (no charge) | ✅ Voided (no charge) |
| next_offered_to_current_renter | ✅ Voided (no charge) | ✅ Voided (no charge) | ✅ Voided (no charge) |
| next_ready_for_owner_review | ✅ Voided (no charge) | ✅ Voided (no charge) | ✅ Voided (no charge) |
Rule of thumb:
Any booking with a confirmed financial commitment (confirmed / active / balance_scheduled / holding) blocks unlisting, pausing, and deletion. A normal pending owner-approval request does not block Unlist and remains open to its deadline, while Pause cancels it. Next-in-line requests never block because no money has moved and are voided by either visibility action.