Docs.

Reservation Lifecycle — Event-Driven

architecture/reservation-lifecycle.md

Status: shipped (2026-06). Reservations advance by events, not by staff clicking "→ status" buttons. Each stage is reached only when its real-world trigger fires and its gate is satisfied. This doc is the operator's map: the stages, what moves a reservation between them, what can leave one stuck, and how the super admin un-sticks it through the agent. For the wider rental-domain model see docs/architecture/rentals.md; for the client side see docs/architecture/client-portal.md.

The stages

StageStored statusHow it's reached
draftedNULLThe reservation is created (a lead → reservation). Invisible to the client until a quote is sent. created_at is the drafted timestamp.
quotedquotedStaff send the quote (office reservation page → send quote).
acceptedacceptedThe client accepts the quote in the portal.
confirmedconfirmedThe deposit clears its threshold (a recorded payment).
returnedreturnedEvery unit is accounted for and the return inspection is signed.
settledsettledThe balance is paid and the remaining deposit is returned.
closedclosedNo open claims remain.
cancelledcancelledStaff cancel (manual, any non-terminal stage).
disputeddisputedStaff raise a dispute (manual, from returned/settled).

Only cancel and dispute are manual. Everything else is driven by an event. A rejected quote (the client requests changes in the portal) moves the reservation back to drafted and records their note as an external comment.

The engine

Every trigger calls one idempotent evaluator, maybeAdvance(reservationId) (src/lib/office/reservations.ts). It promotes the reservation through the system-driven chain — accepted → confirmed → returned → settled → closed — as far as the gates allow, reusing transitionReservation so the audit trail and client emails fire exactly as they would for any other transition. A blocked gate simply stops the chain; the next event tries again.

drafted → quoted (send quote) and quoted → accepted (portal accept) are human actions, not part of the auto chain.

Triggers → maybeAdvance

  • A payment is recorded (addPayment) — a deposit can clear confirmed; a balance charge / deposit return can clear settled.
  • A unit is scanned in (staff app → returnAsset) — the last one can clear returned.
  • A unit is marked lost / damaged / retired (agent updateAssetFields) — accounts for a unit that will never be scanned back in.
  • The return inspection is signed (/reservations/[id]/inspections) — the second half of the returned gate.
  • A claim is closed (advanceClaim) — the last open one can clear closed.

The gates (why a stage won't advance)

EnteringGateError
confirmedNo overlap conflict beyond owned units / blackoutsoverlap_conflict: …
confirmedDeposit collected ≥ thresholddeposit_below_threshold: …
returnedAll units accounted (scanned-in, lost, or damaged)units_outstanding
returnedSigned in condition report on filereturn_inspection_unsigned
settledBalance (total + IVA) paid and deposit net-returnedbalance_unsettled
closedNo open claimsopen_claims

How a reservation gets stuck

A reservation is "stuck" when the next event can't fire, so its gate never clears. The common cases:

  1. A lost unit is never scanned in. The reservation sits in confirmed because allUnitsAccounted is false — there's still a unit bound to it. Fix: mark that unit lost (agent updateAssetFields), which accounts for it; the return inspection then clears returned. If the unit can't be inspected at all, force the stage (below).
  2. The deposit was waived or paid out-of-band. accepted won't move to confirmed because the recorded deposit is below threshold. Fix: record the deposit (preferred, keeps the money trail honest) or force confirmed.
  3. The return inspection was never signed. Units are all accounted for but returned is blocked. Fix: sign the inspection, or force returned.
  4. A deposit was captured to cover damage. balanceSettled treats a captured deposit as not returned, so settled won't clear on its own once damage is kept out of the deposit. Fix: force settled after the claim accounting is done.
  5. A claim is left open. closed is blocked until every claim's status is closed. Fix: close the claim (clears closed automatically) or force it.

The super-admin override (through the agent)

Two agent tools, gated to the super admin (the lifecycle_writes permission group at /office/agent/permissions), handle stuck reservations:

  • reservationLifecycleDiagnose(reservationId) — read-only. Reports the current stage, the next system-driven stage, and every gate with a pass/fail and a one-line detail. Always run this first.
  • forceReservationStatus(reservationId, to, reason) — forces the reservation to to, bypassing the gate but still following a legal transition edge (no arbitrary jumps — force one step at a time). The reason is required and audited (status_forced) on top of the normal status-change row. Carries an approval card.

Recipe

  1. Ask the agent to diagnose the reservation — it names the blocking gate.
  2. Prefer the real fix (record the deposit, mark the lost unit, sign the inspection, close the claim) so the reservation advances on its own and the books stay accurate.
  3. If the real event genuinely can't happen, ask the agent to force the single next stage, with a reason. Approve the card. Repeat per stage if the reservation needs to move more than one step.

Every override is in the audit log as status_forced with your reason and the acting account — the override is powerful, never silent.