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). Seesrc/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 throughPOST /food; see Inventory API. - Group hotel assignments — links a hotel row to a
TravelGroupwith rooms allocated, check-in/check-out, meal plan settings, and an optional price override. Represented by theGroupHotelAssignmenttable. - 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
BookingPassengerHoteljunction (added insupabase/migrations/20260418020000_passenger_hotel_junction.sql) lets ops split a single hotel assignment across passengers with per-passenger check-in/check-out,roomShareGrouplabel, room number, and meal-plan override. Triggers maintain a derivedroomsUsedcounter 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) callscreate_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'sSUP-ledger, plus the caterer's bill), pending finance (FIN-030, FIN-032). The desktop'sPOST /foodbuilds 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) callsupdate_food_inventory: name, caterer, rate, currency and contract rate, capacity, dates, meal-days bought, the low-stock threshold, notes. Sendingquantityis 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)
- Ops creates a
HotelInventoryrow withtotalRoomsandavailableRooms. - From the Groups page ("Hotels" tab) ops creates a
GroupHotelAssignmenttying the hotel to the group, withroomsAllocated,checkIn,checkOut, and meal-plan settings. - Individual passengers on the group's bookings can optionally be placed into specific rooms via the
BookingPassengerHoteljunction (room-share groups, meal overrides). - The booking P&L calculation pulls the group's hotel cost and prorates it across bookings by passenger count — see the
hotelCostcomputation insrc/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
SupplierTransactionrecordsleaseDays × totalRooms × pricePerNightas acredit(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.
Related tables (Supabase)
HotelInventory— the core row. Created insupabase/migrations/20260113130121_remote_schema.sql; lease-date columns added in20260331130000_hotel_inventory_lease_dates.sql; account FK fixed in20260404180000_fix_hotel_inventory_account_fk.sql.FoodInventory— same page, parallel table.GroupHotelAssignment— links group × hotel. Lives in the original schema migration; meal fields added in20260403010000_hotel_assignment_meals.sql; date fields in20260403000000_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).