Internal Audit Document

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.

HOLD_DRAFT (booking_wizard)TTL: 15 minutes

Created the moment a renter opens the BookingWizard date-picker for a specific listing.

Creation checks (placeReservationHold):

  • Listing must be status = active
  • No BookingNight with status 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).

HOLD_PENDING (pending)TTL: 24 hours

Upgraded from HOLD_DRAFT when the renter submits the booking request (after contract sign + payment method capture).

HOLD_DRAFTbooking submittedHOLD_PENDING

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:

Owner acceptslockBookingNights()Booking → confirmedHold → converted
Owner declinesBooking → declinedHold → cancelledreleaseBookingNights()
24h passes, no actionBooking → expired_unpaidHold → expiredreleaseBookingNights()
TriggerBookingNight.status writtenFunction
Owner approves booking (non-instant)confirmedlockBookingNights()
Instant-book: renter submitsconfirmedlockBookingNights()
Deposit charged (chargeDepositAndConfirm)confirmedlockBookingNights()
Booking goes active (check-in day)activeprocessScheduledPayments (status mutation)
Booking cancelled / declined / expiredreleasedreleaseBookingNights()
Payment capture fails (≤48h)releasedreleaseBookingNights() via cancelBooking
Owner early-terminatesreleasedrequestEarlyTermination → 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 below
  • occupancy_booking_id — FK to current occupant
  • dibs_expires_at — auto-void TTL
  • dibs_reason — renter message to owner
  • total_price = 0 — no auth until activated
  • platform_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

next_pending

Queued; occupancy still active

next_offered_to_current_renter

Renewal window open; current renter notified

next_ready_for_owner_review

Vacancy confirmed; owner must accept/decline

next_voided_by_renewal

Current renter renewed; dibs cancelled (no charge)

next_expired

TTL elapsed or listing removed (no charge)

pending

Owner accepted; booking_kind reset to standard; normal flow resumes

How it blocks duplicate dibs (submitNextInLineRequest):

  1. Query all active dibs for listing — check date range overlap (not just exact date match)
  2. Check for existing ReservationHold.hold_type = next_in_line on listing
  3. Check if requesting user already has a dibs on this listing
  4. 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:

checkout − 5dnext_offered_to_current_rentercurrent renter notified
Current renter renewsnext_voided_by_renewalDibs cancelled (no charge, no hold)
Renter does NOT renew → checkoutnext_ready_for_owner_reviewOwner gets 24h to accept/decline

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 StatusUnlistDeactivateDelete
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.

Generated from live function code — placeReservationHold, lockBookingNights, releaseBookingNights, processNextInLineBookings, submitNextInLineRequest, manageListing, expirePendingBookings