Skip to content

Hotels

Written April 2026 — read this first

Still broadly right. Room and bed counters are now derived together by the database from the group assignments, so they cannot disagree and cannot be typed from the browser, and an allocation past the contract is refused rather than clamped (INV-013). Rooms can be held with an expiry — see Holds, deadlines and seat releases.

Hotel inventory module — tracks contracted room stock from hotel suppliers and allocates rooms to travel groups. This is where ops records a hotel contract, how many rooms are held, the night rate, and which group consumes them.

Scope

The Hotels module manages contracted/leased room stock for Hajj and Umrah groups (Makkah, Madinah, Jeddah, etc.). It is not a hotel search/booking aggregator — every row is a room block the agency has negotiated with a supplier, not a free-sell inventory feed.

What it manages

  • Hotel records — one row per contract: supplier, city, star rating, distance from Haram, room type, total rooms, available rooms, rate/night, currency + exchange rate, lease date range, low-room threshold, GL account linkage, and optional GST input-credit fields.
  • Food inventory — the same page hosts a parallel "Food" tab that tracks catering contracts (unit meals, quantity/day, price/meal-day, supplier, currency + exchange rate, date window). See src/pages/hotels/Hotels.tsx. A contract billed in riyals must carry the rate it was signed at: the purchase voucher and the group cost report both convert with it, and a departure holding a foreign-currency contract with no rate cannot be costed at all (FIN-034). The Food tab does not yet have a field for it — it goes in through POST /food; see Inventory API.
  • Group hotel assignments — links a hotel row to a TravelGroup with rooms allocated, check-in/check-out, meal plan settings, and an optional price override. Represented by the GroupHotelAssignment table.
  • Food assignments — the counterpart for catering: a group × food item with meal-days, rate and a head-count. Catering is bought at a rate per pilgrim per day, so the head-count is the whole cost (INV-040). Two numbers are kept: travellers fed (quantity — what the caterer cooks for and what draws the contract down) and travellers billed (chargeableQuantity — the cost basis of the Stock-in-Hand → Food Expense journal and of the departure's cost report).
  • Per-passenger room splits — a BookingPassengerHotel junction (added in supabase/migrations/20260418020000_passenger_hotel_junction.sql) lets ops split a single hotel assignment across passengers with per-passenger check-in/check-out, roomShareGroup label, room number, and meal-plan override. Triggers maintain a derived roomsUsed counter on the parent assignment.

Supporting tabs in the UI: city-level stat cards (properties, total rooms, vacant, occupied), low-room threshold dialog, CSV import for food, and Excel export of assignments.

Dates and rooms: what the system checks

An allotment's lease runs from its first night to its check-out day: "01–30 Nov" is 29 nights, the same count the purchase voucher uses. When you give rooms to a departure, the system refuses, and says why, if:

  • the stay has no check-in or check-out date, or checks out on or before it checks in
  • any night falls outside the allotment. The message names the nights, for example 27/10/2026 – 31/10/2026
  • any night falls outside the departure's own dates
  • on any night, the rooms or beds asked for exceed what is free that night. The message names the night and the numbers

Groups on different nights can each use the whole allotment. Rooms free on a hotel card is the allotment minus its busiest night. You cannot shrink an allotment below a night in use, or move its lease so a departure's stay falls outside it. Reducing rooms or shortening a stay is always allowed. See INV-031 and INV-032.

The Allotment calendar shows the lease's nights only: the check-out day is not a night.

Assign and unassign on a hotel card

A hotel can be let to more than one departure, so the card on the Hotels tab always offers Assign Rooms. It offers Unassign only when the hotel is linked to exactly one group, and the confirmation names that group. When the hotel is linked to two or more, the card says how many and sends you to the Assignments tab, where every row names its own group and removes only that row.

The card never picks a link for you. An allocation belongs to one departure (INV-010), and releasing the wrong one puts another group's rooms back on the shelf without anyone being told.

Assigning a meal plan to a departure

Both meal-assign dialogs — the Food page's Assign button and the Groups page's Meals panel — say what will happen before you save: how many travellers the departure feeds and how many it is charged for.

The fed head-count comes from the departure itself (active travellers who bought meals). Leave Travellers fed blank and the plan follows the manifest: a pilgrim joining, cancelling, transferring or opting out moves the head-count, the contract's stock and the cost voucher on their own (INV-042). Type a number to fix it — the plan then says "typed — does not follow the manifest" and stays until you change it. A contract is bought in meal-days (pilgrims × days); the Food page shows drawn / bought, and says in red when a contract has more on it than it bought. Ticking "exclude children/infants" or "exclude group leader" does not take anyone off the meal list — those people eat, and they are on the count sent to the caterer. It takes them off the bill (INV-040).

The confirmation names both numbers, and the consumption journal and the departure's cost report are posted on the billed one.

Currency and GST

Hotels are typically priced in SAR. The stored exchangeRate is the contract rate (1 foreign unit = N INR) and is persisted on the hotel row so later recomputes use the contract rate, not today's spot rate. When gstEnabled is true, the purchase journal splits the base amount to the hotel-expense account and the GST portion to 1400 GST Input Credit. See src/lib/api.ts (POST /hotels).

Food in the native app

The staff app (apps/mobile, Inventory → Food) lists every meal contract with its caterer, city, dates, rate per pilgrim per day and how many meal-days are drawn and free, with a red chip when a contract is over what it bought (INV-042). A contract opens to its figures and the departures drawing on it (read-only there — plans are changed from the departure's Services screen, Groups).

  • New contract (food.create) calls create_food_inventory, which validates the contract in the route's words — a caterer, a rate and a daily capacity are required; a riyal contract with no typed rate takes the day's rate from Finance or is refused (FIN-034) — writes the row and posts its purchase voucher from the database (Dr Stock-in-Hand 1310 [+ GST Input Credit] / Cr the caterer's SUP- ledger, plus the caterer's bill), pending finance (FIN-030, FIN-032). The desktop's POST /food builds the same voucher in the browser. A voucher that cannot post (a locked period, a missing ledger head) leaves the contract standing and is queued under Unposted entries; the screen says so. Meal-days bought default to pilgrims a day × days when left blank.
  • Edit (food.edit) calls update_food_inventory: name, caterer, rate, currency and contract rate, capacity, dates, meal-days bought, the low-stock threshold, notes. Sending quantity is refused (it is the derived free stock); a total below what departures have drawn is refused by the counters (INV-012). The purchase voucher already posted is not restated by an edit, as on the desktop.
  • Not built in the app: deleting a contract, the CSV import, setting one threshold for every contract (/food/thresholds), and printing.

Pages

  • src/pages/hotels/Hotels.tsx — the page for this module, with a Hotels tab and an Assignments tab, plus all create/edit/assign/threshold/import dialogs. Route: /hotels. Catering moved out to its own page (/food) and is no longer a tab here.
  • No separate detail route — hotels are edited inline via dialog.

Bundle size

Despite being a ~2,600-line file, the Hotels route gzips to roughly 10.7 KB at build time (budget 13 KB). The lean footprint is enforced by scripts/check-bundle-budgets.mjs. Heavy utilities (XLSX export) are lazy-loaded so they don't tax the initial page load.

What a hotel is called

A hotel card is titled with the property's name — the one on the building and on the rooming list. The supplier is a separate line underneath. The cards used to be titled with the supplier and then repeat it, so a hotel's own name never appeared anywhere on the page.

Choosing a supplier still offers its name, because a small supplier often is the property. But it only fills in a name that is empty or was carried over from the previous supplier — it never overwrites a name someone typed, including while editing an existing hotel. That overwriting is why some properties were recorded under the name of the person they were rented from; correcting such a record is a matter of editing the hotel and typing its real name.

Printing

Both tabs print through the shared report scaffold (src/lib/printReport.ts), so a hotel report looks like every other printed page — same header, same fonts, same table style.

  • Hotels tab → Print Inventory — the whole page: the per-city scoreboard, the properties and the group assignments.
  • Assignments tab → Print — the assignments on their own, exactly the rows on screen, so a group filter carries through to the paper and the heading names the group it was filtered to.

Both tables are built by one function, so the two can never say different things about the same rooms. A hotel linked to a departure whose dates are not filled in yet has no nights, so it shows no cost rather than a false zero.

Relationship to bookings

Hotels attach to groups, not directly to bookings. The chain is:

Booking ──(groupId)──▶ TravelGroup ──(GroupHotelAssignment)──▶ HotelInventory
                              │
                              └──▶ BookingPassengerHotel (per-pax room assignment)
  1. Ops creates a HotelInventory row with totalRooms and availableRooms.
  2. From the Groups page ("Hotels" tab) ops creates a GroupHotelAssignment tying the hotel to the group, with roomsAllocated, checkIn, checkOut, and meal-plan settings.
  3. Individual passengers on the group's bookings can optionally be placed into specific rooms via the BookingPassengerHotel junction (room-share groups, meal overrides).
  4. The booking P&L calculation pulls the group's hotel cost and prorates it across bookings by passenger count — see the hotelCost computation in src/lib/api.ts (around lines 1005–1015, 4531–4543).

When an assignment is created, a journal entry posts Dr Hotel Expense / Cr Stock-in-Hand to recognize consumption. When an assignment is deleted, the journal is auto-reversed.

Supplier linkage

Every hotel row must belong to a supplier. handleHotels (POST /hotels in src/lib/api.ts) calls ensureHotelSupplierRecord(body.supplierId) and throws a 400 "Select a hotel supplier first" if omitted. The supplier's name is copied into the hotel row's hotelName field, and the hotel's supplier drives the payable ledger:

  • On hotel creation, a SupplierTransaction records leaseDays × totalRooms × pricePerNight as a credit (payable) against the supplier.
  • If the contract currency isn't INR, the INR-denominated payable is computed using the stored exchangeRate.
  • The supplier view (/suppliers/:id) lists all linked hotel contracts and their payable/payment history.

Supplier is immutable on the hotel row

The Hotels module does not expose an edit path that changes supplierId after creation — once a contract is tied to a supplier, reassigning it means deleting and re-creating. This keeps the ledger trail consistent.

Permissions

Route guard: hotels.view (src/App.tsx:175). All mutating actions on this page are wrapped in <PermissionGate>:

Action Permission Location
View /hotels route hotels.view src/App.tsx
Add Hotel / Add Food hotels.create <PermissionGate> on "Add Hotel" + "Add Food" buttons
Edit hotel fields / assign rooms / set thresholds / assign food / edit food hotels.edit <PermissionGate> on edit + assign buttons, server-side requirePermission('hotels.edit') on PATCH routes
Delete hotel / delete food item / delete assignment hotels.delete <PermissionGate> on delete buttons, server-side requirePermission('hotels.delete') on DELETE routes
Export hotels/food workbook hotels.export <PermissionGate> on export buttons

Server-side enforcement lives in src/lib/api.ts (grep for requirePermission('hotels.). Nav item appears in the sidebar as "Inventory → Hotels & Food" when the user has hotels.view.

See docs/PERMISSIONS.md §6.14 for the authoritative matrix row, and §5 for which roles carry hotels.*.

Issued documents

The office issues a hotel voucher for a booking as a PDF — one per hotel stay of the booking, Alhuda's own voucher and not the hotel's confirmation, and it says so on its face (TRV-013). It carries the booking number and group code, the hotel (name, city, stars, distance from the Haram), check-in and check-out, nights, room type, the rooms, the guests (name, last four of the passport, room number where allotted), the office's contact for emergencies and a terms line. The hotel's own confirmation number is not stored anywhere, so the voucher names the group booking as the reference at reception.

Where: the native app's booking screen, Documents → Issue hotel voucher (hotels.view + bookings.view; The native app) and the desktop booking page's Documents card on the Overview tab (Issue hotel voucher, the same pair of permissions). The edge function issue-document (kind hotel_voucher, optional assignmentId for one stay) checks both permissions, renders the voucher from BookingPassengerHotel → GroupHotelAssignment → HotelInventory, stores it on the company Shared Drive under Issued / the booking (ACC-074) and records it in IssuedDocument (issue_document_record, one live voucher per booking and stay; a re-issue supersedes). Refused, with the reason: a booking with no rooms assigned yet, a booking finance has not confirmed, a cancelled booking. The traveller opens it under My documents → Hotel, the partner under Booking → Documents, staff with bookings.view anywhere the row is read. Rooms are not changed by issuing: the voucher is prepared from the allocation, never the other way round.

  • HotelInventory — the core row. Created in supabase/migrations/20260113130121_remote_schema.sql; lease-date columns added in 20260331130000_hotel_inventory_lease_dates.sql; account FK fixed in 20260404180000_fix_hotel_inventory_account_fk.sql.
  • FoodInventory — same page, parallel table.
  • GroupHotelAssignment — links group × hotel. Lives in the original schema migration; meal fields added in 20260403010000_hotel_assignment_meals.sql; date fields in 20260403000000_hotel_assignment_dates.sql.
  • BookingPassengerHotel — per-passenger junction; 20260418020000_passenger_hotel_junction.sql.
  • IssuedDocument — the issued hotel vouchers (and e-ticket sheets); 20260929120000_an_eticket_and_a_voucher_are_files.sql.
  • SupplierTransaction — payable ledger against the supplier.

In the native app

The native app (staff, Operations → Hotels, hotels.view) lists the blocks by city with the same counters and low-rooms warning, opens a block with the departures holding its rooms, and edits the fields that do not move the lease cost (hotels.edit).

New hotel block (hotels.create) is the desktop form on a phone, saved through one database call, create_hotel_lease (20260929020000_a_hotel_lease_posts_its_own_purchase.sql). It inserts the row through create_hotel_inventory and then post_hotel_purchase_from_hotel derives and posts the purchase voucher from the row — the same lines POST /hotels builds in the browser: nights (lease dates, else check-in/out, else one), rooms × rate, INR at the contract rate rounded to paise, Dr 1310 Stock-in-Hand, Dr the GST input head from Finance Settings (default 1400), Cr the supplier's SUP-<8 of the id> ledger under Sundry Creditors, and the SupplierTransaction (credit, contract currency, hotel_inventory) — pending (FIN-030, FIN-032, FIN-034). Both land or neither does. Idempotent: a hotel that already carries a hotel_purchase journal is left alone. Stricter than the browser in one place: with no FinanceConfig row it refuses rather than guess. Parity is proved by supabase/tests/a_hotel_lease_posts_its_own_purchase.sql, whose expected figures are derived by hand from the browser code (6 quad rooms × 12 nights @ 130 SAR @ 26 = ₹2,43,360, GST 100 SAR = ₹2,600, payable ₹2,45,960).

Since issue #559 step 5 a block also has Assign to a departure (hotels.edit, assign_hotel_rooms — the desktop's Assign Rooms), and each departure's row Place travellers (bookings.edit, place_travellers) and Take these rooms back (hotels.edit, unassign_hotel_rooms). Who is staying (hotels.view and bookings.view) lists the travellers by departure with booking, room and dates, and Offered to partners (hotels.view and agents.view) the beds on offer, read only. Details: native app.

Not in the app: deleting a block; changing rooms, rate, currency, dates, GST or the supplier, and changing a stay's rooms, rate or dates (the desktop reverses and re-posts the voucher from the browser — FIN-030, "Not covered yet"); thresholds by city, export and print.

How the screen loads

The hotels screen loads with one request (PRF-010): hotels_screen, read as you, brings the hotels, departures, room lettings, suppliers, payment accounts, today's exchange rates and the B2B bed offers the dashboard stripes (left out without agents.view). Before, it took 39 requests in 16 round trips, growing with every supplier. A hotel's allotment calendar is drawn from the same answer and makes no request of its own. The city list behind the hotel and threshold forms is read when a form opens, once per session. The screen opened again shows at once and refreshes behind. Routes: Operations screens API.

Cards or a list

The inventory shows as cards or as a list — the switch is beside Print Inventory and the choice is remembered in this browser. The list has one row per hotel: the hotel, its city, stars and distance from the Haram, the room type, rooms left out of the total with a bar, beds allocated, the rate per room night, the lease dates and the status, with Assign, the allotment calendar and Details (which opens the hotel's card for edit and unassign). The search, the lease range and the sort apply to both (UX-030).