Skip to content

0024. Inventory, reservations and availability

  • Status: Accepted
  • Date: 2026-10-27

An administrator creates room types and physical units. A permitted receptionist creates or finds a workspace guest, books one or more rooms, and reloads the saved detail with frozen prices, assignments and a version. Calendar, front desk and availability reads agree with that booking. Assignment, check-in and check-out update persisted state; check-out makes the unit dirty. Cancellation releases capacity atomically. Invalid input, missing access, stale versions and capacity/assignment conflicts leave no partial writes. Two concurrent bookings for the last room produce exactly one success. Another workspace cannot read, reference or modify any of these records. These are backend/API/SDK criteria; operator screens are deliberately outside this branch.

Reservation (commercial), reservation room (sold capacity) and stay segment (physical assignment) are distinct. Stays use Temporal PlainDate and half-open intervals. The maximum stay is 366 nights, at most 20 rooms per request. Stay/calendar dates use civil years 0001–9999; Temporal year zero is refused before it can reach PostgreSQL. Each room stores one frozen price per night, in the property’s currency. A stay total divides integer minor units evenly, with the remainder added one minor unit at a time to the earliest nights. Changing dates/type requires a complete replacement price input; occupancy-only edits may retain prices.

Every property write first takes a FOR UPDATE property gate, including setup and housekeeping. Inventory deltas are applied in one ordered set-based statement. The gate protects existing and absent inventory rows, so separate ledger FOR UPDATE queries add no protection and are omitted. Inserts propose the final sold count (existing + delta): a negative excluded insert would violate CHECK before PostgreSQL reached ON CONFLICT. Nights, room rows and segments are written in batches. Only changed rooms are considered; nights and segments are compared separately. A notes PATCH or check-in never rewrites frozen nightly rows. Different properties remain independent.

Reads run through the unit of work in REPEATABLE READ READ ONLY, keeping transaction-local RLS context and timeouts. They take no row locks and do not wait for a property writer. Details use five bulk reads (header, guest, rooms, nights and segments); lists use two (header summaries and all rooms with names/totals), independent of page size. Read-model queries stay in one snapshot. Property settings updates still use the writer gate. Availability derives active unit counts minus the ledger, and booking checks that same read. Currency changes are refused once reservations exist. An exclusion constraint on workspace, unit and daterange prevents overlapping active segments. A segment remains as history but becomes inactive on cancellation/check-out. Departed rooms retain historical sold nights, but release today and later nights on early checkout. Tentative reservations consume capacity. In-house moves/upgrades are refused. No deliberate overbooking. Reservation changes require a strong If-Match containing the quoted integer version. Guest edits use the same protocol, with a guest row lock to avoid a lost-update race. Stale requests return 412 and the current ETag; an empty reservation PATCH retains its version, timestamp, audit and event state.

Audit entries and thin domain events are in the same transaction as writes. Reasons are stored only on tenant records, never in audit/event payloads (which record identifiers and safe flags). Guest contacts are not unique. The migration resolves the extension schema in pg_extension and creates guestcraft.fold_search(text), a stable, strict SQL function. This narrowly scoped SECURITY DEFINER function has search_path=pg_catalog, accepts only text and calls the qualified unaccent function/dictionary; it reads no tables and cannot execute caller SQL. PUBLIC loses execution in the migration; the inventory grants only app/worker. It works even when the extension schema is private, without granting runtime roles access to that schema. Searches use literal case/accent-folded substrings; phone searches also compare digits only. The API canonicalizes language through the existing i18n helper and uppercases country before core validation. Domain field errors name actual input paths, including room indexes and price/total_amount or price/nightly_amount.

Organization owner/admin resolves to workspace admin (ADR 0017); there is no separate workspace owner role. Every workspace role reads. Admin alone configures room types/units. Admin, front_desk and revenue_manager create/update guests and reservations and assign units. Admin/front_desk check in/out and cancel. Admin alone may override a dirty-room check-in with a required reason. Admin/front_desk/housekeeping update housekeeping status.

Operation admin revenue_manager front_desk housekeeping accounting viewer
Read yes yes yes yes yes yes
Configure/archive types and units yes no no no no no
Guests, reservations and assignments yes yes yes no no no
Check-in/out and cancel yes no yes no no no
Housekeeping status yes no yes yes no no
Readiness override with reason yes no no no no no

Forbidden actions are 403; a resource outside the selected workspace/property is 404.

Revision 1.4.0 has 33 inventory/reservation operations under the property, plus workspace /guests. Besides CRUD and archive it supports unarchive, single-unit detail, atomic bulk housekeeping (up to 100 distinct units), assignment, check_in/out and same-day undo_check_in and undo_check_out. Units expose housekeeping_updated_at and sort by sort_position, then natural name order (explicit en collation with numeric comparison), then ID. For example, 2 < 11 < 101. Room type codes stay reserved when archived; unarchive is the recovery path. Unit names may be reused after archive; unarchive refuses an active duplicate name and requires the room type to be active.

Calendar excludes cancelled stays and archived setup by default; include_cancelled and include_archived opt in. A unit_id/name comes only from an active segment. Revision 1.4.1 adds nullable stayed_unit_id/name for a departed room’s inactive segment, preserving past physical occupancy without implying a live assignment. Cancelled stays never acquire historical occupancy; an early departure ends at effective_departure. The current model has one segment per room and refuses mid-stay moves; multi-segment history remains planned. effective_departure is the earlier of released_on and booked departure; checked_out_on also preserves late departures. Stays include guest ID, type/unit names, notes_present and a frozen room total. Front desk includes overdue in-house departures, pending/completed arrivals and departures, readiness and housekeeping counts. occupied_units is physical in-house occupancy; sold_rooms is the nightly ledger, including expected future arrivals. Front-desk date bounds are 0001-01-01 through 9999-12-30 (the following night boundary must also be in the civil range).

Reservation lists return ReservationSummary, with booker ID/name, aggregate dates, room count, named per-room status/assignment/dates/totals and version. Contacts and nights belong to detail only. Detail also includes referenced room type and segment unit names for a direct detail-screen reload. Sort values are arrival:asc/desc, departure:asc/desc, created:asc/desc and code:asc/desc, with ID as the tie-breaker; default created:asc. Filters include the existing stay/date filters and guest_id, unit_id (active assignment), source, created_from/to and include_cancelled. An explicit confirmation filter takes precedence over the default cancellation exclusion. GET /guests/{id}/reservations supplies workspace guest history. Guest/reservation pages remain bounded at 100 and expose has_more, with no total_count: counting all matches would add an unbounded read to every page. Cursors bind organization, workspace, parent, filters and ordering. They are live pagination, so editing a sort key between pages can move an item; they do not promise an export snapshot.

GET units accepts paired free_from/free_to (up to 366 nights), excluding archived units and active overlapping segments while returning housekeeping status. Adjacency is allowed. A room PUT can change type and clear or replace unit_id in one mutation. Omission retains the current assignment. Date/type changes require a complete price; occupancy-only edits may retain independently frozen nights. Create and room edit support validate_only=true: 200 with proposed detail, nightly split, total and post-change availability, or the same 409 conflicts as saving. They persist no guest, inventory, reservation, audit or event. Transient identifiers/version are a proposal, never a saved resource. A create preview has no Location or ETag; a room preview retains the current ETag. Submission revalidates under the writer gate; a quote neither holds capacity nor guarantees a later save.

New arrivals before property today are refused for sales/front_desk, including date edits; admin may enter historical stays. Property today remains the injected instant in the explicit IANA zone, not the UTC date. Undo is admin/front_desk only and must match the persisted action’s property day. Undo checkout rechecks capacity and active assignment conflicts, retaining dirty housekeeping until an explicit readiness update. Invalid state, failed recovery or a stale version writes nothing. These are local lifecycle corrections, not a policy engine or a persistent quote/rate-plan implementation.

Event names are past tense: reservation.room_changed, unit_assigned, unit_unassigned, checked_in, checked_out, check_in_undone and check_out_undone, each @1. Room actions include room_id and unit_id; reasons stay only on tenant rows. RFC 9457 errors have IT/EN texts, precise field paths and structured full-night/holding-code conflicts. Archived edits use inventory.archived (409); invalid dirty-room override reasons use 422, unauthorized dirty room overrides use 403; unnecessary overrides on ready units are ignored.

This user-authorized second round refines the unmerged, unpublished 1.3.0 contract in PR #21: summary list shape and mandatory guest preconditions replace its first-round forms. The coordinator must regenerate/rebase unpublished consumers before merge. It is an explicit pre-release exception to ADR 0016’s additive-only published v1 rule. The brief also authorizes editing unapplied migration 0006 in place, an explicit exception to ADR 0011’s append-only rule; no deployed database may apply a changed migration checksum.

The next round’s Today, Planning, Reservations, New reservation, Rooms and Settings screens can obtain their principal read model and assignment choices in one or two requests. This branch intentionally delivers backend/API/OpenAPI/SDK functionality only. Acceptance adds cancel/rebook and early/late departure rendering, overdue visibility, max-cap SQL budgets, read isolation without writer blocking, non-public extension search, preview/no-op persistence, guest stale-write protection, natural ordering, archive recovery and undo conflict recovery to the original workflow tests. The assigned implementation brief is the accepted scope for this branch in place of a separate feature issue. No new runtime dependency or paid service is introduced.

Since implemented: rate plans (ADR 0027) and ARI (ADR 0034), Channex mapping and booking import (ADR 0028), out-of-order blocks (ADR 0033), folios and payments (ADR 0029), no-show and cancellation policies (ADR 0032), night audit and business date (ADR 0043), upgrades and mid-stay moves (ADR 0042). The list as decided:

Rate plans and ARI; Channex mapping; booking import and sync; out-of-order blocks; groups and allotments; folios, payments and taxes; no-show and cancellation policies; night audit and business date; guest merge; upgrades and mid-stay moves; occupant/registration profiles; per-room cancellation; complete version history; inventory date partitioning and retention. Cancellation reinstatement (must recheck capacity/assignments before implementation); a housekeeping-specific projection that omits guest contacts and operational reasons (review L8); persistent quote expiration/price versions; total_count/export snapshots. Full guest detail still follows the original all-workspace-role read grant; applications must not mistake the compact summary for a comprehensive data-minimization policy.

ADR 0042 lifts the single-segment model and the refusal of in-house moves and upgrades: a room keeps consecutive segments, each night consumes the type of its assigned unit, and move and split actions change them under the same writer gate, If-Match, audit and exclusion rules. The assignment endpoint keeps requiring a unit of the booked type.