Skip to content

02 · Passengers, children and infants

One classification, worked out one way, used identically by pricing, seats, beds, meals, transport, visa, invoices, refunds and reports.

Why this file exists (findings, 2026-09-17)

  • The category (adult / child / infant) is a free text value copied separately by each screen. Imports and the public form measure age today; the wizard measures it on departure; reports use whole calendar years. Nothing re-checks it later.
  • Children and infants are refunded at the adult rate when their own rate is blank or zero (api.ts cancellation paths use rate || adultRate).
  • Booking-level adult/child/infant counts are typed in and drift from the actual passenger rows; invoices show one and bill the other.
  • Infants are left out of airline manifests but consume B2B seats and, without a room-share group, a whole hotel room.
  • The only guardian link is free-text motherName; there is no minimum-adult or infant-per-adult rule.
  • Titles/categories are spelled INFANT, Infant, infant in different places, so several checks never match.
  • A passenger had to have both a first and a last name. On the 29 Aug 2026 departure 11 of 27 pilgrims have a blank passport surname field, so staff invented a split that matched neither the passport, the PNR nor the visa (audit 2026-09-18, gap G8).

Classification

PAX-001 · Categories

Status: PROPOSED · Owner: Operations · Source: IATA passenger type codes (ADT / CHD / INF) Every passenger is exactly one of ADULT, CHILD, INFANT. Stored in one field, one spelling, on the passenger. No other field (title, booking counts, remarks) decides category. Enforced by: 20260918120000_booking_lifecycle.sql: BookingPassenger_category trigger stores adult/child/infant (existing check constraint); a typed category is overwritten.

PAX-002 · Age cut-offs

Status: OPEN · Owner: Management Proposed: INFANT under 2 · CHILD 2 to 11 · ADULT 12 and over. Decide: are package cut-offs the same as airline cut-offs? Does Hajj use different ages? Enforced by: Configurable, not decided: PassengerCategoryConfig (20260918120000_booking_lifecycle.sql) holds the cut-offs with the PROPOSED defaults (infant < 2, child 2–11, adult ≥ 12), readable by every signed-in user and editable only with admin.config.edit (/config/passenger-categories). Changing it recategorises passengers whose trip has not started. Management to confirm.

PAX-003 · Age is measured on the service date

Status: OPEN · Owner: Management Proposed: category is calculated per service from date of birth and the date that service is used — - flights: each flight's departure date (a child who turns 2 before the return flight is INFANT outbound, CHILD on return and needs a paid return seat); - hotel, meals, transport, package price: group departure date. Alternative: one category for the whole trip, measured on departure. Enforced by: Interim, pending decision: age in whole years on the group departure date (booking date when there is no group) — passenger_category(dob, on_date) in 20260918120000_booking_lifecycle.sql. Per-flight-leg measurement is a later refinement.

PAX-004 · Date of birth is mandatory

Status: PROPOSED · Owner: Operations Date of birth is required for every passenger before a booking leaves DRAFT. Category is calculated, never typed. A manual override is allowed only with a reason, by a user with override permission, and is recorded in the audit trail. Enforced by: Partly — category is calculated by trigger; a passenger without DOB has no category and flags Booking.passengersIncomplete; submit_booking / ops_decide_booking / resubmit_booking refuse a booking with a missing DOB or passport. The manual override with reason is not built.

PAX-005 · Re-check when anything changes

Status: PROPOSED · Owner: Operations Category is recalculated whenever date of birth, group, or travel dates change — on every screen, import and API path — and the price, entitlements and capacity follow. Enforced by: 20260918120000_booking_lifecycle.sql: recalculated on DOB change, booking group change (move_booking_to_group), group departure date change and config change. Price does not follow automatically after creation. Since 20261007100000_booking_db_guards.sql an infant who becomes a child (a corrected date of birth, a later departure date, a lower age limit) takes a seat and the capacity check runs (INV-002); a departure-date or age-limit change that would overbook a departure is refused, naming it. On a booking that has left sales a browser cannot change a date of birth in a way that changes the category (PRC-003). Test: supabase/tests/booking_db_guards.sql.

PAX-006 · Booking counts are derived

Status: PROPOSED · Owner: Finance Adult/child/infant counts on a booking are calculated from its active passengers. They are never entered by hand. Enforced by: 20260918120000_booking_lifecycle.sql: bl_recount_booking keeps adult/child/infant/passenger counts equal to ACTIVE passenger rows; counts typed by a browser session are ignored.

Status: PROPOSED · Owner: Operations · Source: passports issued in J&K; ICAO 9303 TD3 (a holder with one legal name is encoded with that name in the surname field and no given names) A person has a name, not two name parts. Where a passport's surname field is blank the whole legal name is in the given-name field, and that single name is the complete name — it is never split, abbreviated or padded to make two parts. One name is stored in one place: the given-name field, whichever field it arrived in. A blank surname has exactly one representation (the empty string — never NULL, never whitespace). Where a display, manifest, ticket, visa case, invoice or printed document needs a full name, the single name is the full name, and a list sorted by surname sorts such a person by the name they have. Enforced by: 20260924100000_single_part_legal_name.sql — person_full_name() (the one formatter), a BEFORE INSERT OR UPDATE normaliser and a <table>_has_a_name CHECK on Customer, CustomerPassenger and BookingPassenger, and the name validation in create_booking and portal_insert_partner_passengers reduced to "a name is required". Tests: supabase/tests/single_part_legal_name.sql, src/lib/api.singlePartName.test.ts.

Entitlements

PAX-010 · What each category gets

Status: OPEN · Owner: Operations (confirm per airline/hotel contract)

Service ADULT CHILD INFANT Proposed default
Flight seat Seat Seat No seat (on adult's lap) Industry standard
Airline manifest Listed ADT Listed CHD Listed INF (airlines require it) Required
Group capacity Counts Counts Does not count Current behaviour
B2B / partner seat quota Counts Counts Does not count Align with seats
Hotel bed Bed Decide: with bed / without bed No bed; shares guardian's room —
Meals Full Decide: full / child / none None —
Ground transport seat Seat Seat No seat —
Visa Required Required Required Saudi visa needed for every traveller
Own passport Required Required Required Indian passports are individual

PAX-011 · Infants per adult

Status: PROPOSED · Owner: Ticketing · Source: airline rules At most one infant per adult on the same booking and flight. An airline contract may set a lower block infant quota (AIR rules), which also applies. Enforced by: 20260918120000_booking_lifecycle.sql: bl_booking_problems — at most one infant per adult on the booking, checked on submit, ops approval and resubmit. Airline block infant quotas are not checked.

PAX-012 · Infant must share the guardian's room

Status: PROPOSED · Owner: Operations An infant is assigned to the same hotel room as their guardian (PAX-021) and never occupies a room alone. Enforced by: Partly — 20261001220000_families_per_departure.sql: family readiness (family_readiness(), the group page's readiness panel, the app manifest) counts an infant placed in a hotel where the named guardian has no room, or is in a different room (different room number or room-share label). Placing is not refused yet: place_travellers() and the single hotel assignment still accept an infant apart from the guardian. Next step: refuse it there, with the guardian's room offered.

Guardians and minors

PAX-020 · Minimum adults

Status: PROPOSED · Owner: Operations Every booking with a CHILD or INFANT includes at least one ADULT, unless the minor is added to another booking in the same group with a named guardian. Enforced by: 20260918120000_booking_lifecycle.sql: at least one ADULT on a booking with a CHILD or INFANT, checked on submit, ops approval and resubmit. The "named guardian on another booking" exception (PAX-021) is not modelled.

PAX-021 · Guardian is a real passenger

Status: DECIDED 2026-09-28 · Owner: Owner · Source: the owner's approval of the family design, 27–28 Sep 2026 Every CHILD and INFANT (by category on the departure date — PAX-001, PAX-003) names a guardian: an ADULT traveller live on the same departure. The guardian may be on another booking, and in another family; the screens show which booking. Mother's name remains a document field, not the guardian link. The guardian is recorded before departure; it is not a condition of booking approval. When the guardian leaves the departure (cancelled, transferred, booking moved or deleted), the link is cleared and the minor shows "no guardian travelling" until someone names another — nobody is picked automatically. Enforced by: 20261001220000_families_per_departure.sql — BookingPassenger.guardianPassengerId, written only by traveller_set_guardian() (SECURITY DEFINER; bookings.edit, or a partner when both travellers are on their own bookings; audited guardian_set / guardian_cleared). A trigger refuses a guardian who is not an adult, not live on the minor's departure, or given to anyone who is not a child or infant, and refuses a browser write of the column. The follow triggers clear a link that no longer holds (audited guardian_left). Readiness counts minors without a travelling adult guardian. Tests: supabase/tests/families_per_departure.sql.

PAX-022 · Mahram and minors travelling without a parent

Status: OPEN · Owner: Management · Source: Saudi Ministry of Hajj & Umrah / Nusuk (verify current rules) Decide the company policy for women and minors travelling without a mahram or parent, and which documents (e.g. no-objection letter) are required. Note (2026-09-28): the data a policy would need now exists — who travels in which family and how they relate to its head (PAX-036), and each minor's guardian (PAX-021). Nothing checks a mahram or a no-objection letter; nothing is built until this is decided.

PAX-023 · Hajj-specific passenger rules

Status: OPEN · Owner: Management · Source: Haj Policy of India (verify current year) Record age limits and document requirements for Hajj pilgrims, which can differ from Umrah.

Money

PAX-030 · Price by category from the rate sheet

Status: OPEN · Owner: Management Proposed: each group rate sheet has prices for ADULT, CHILD WITH BED, CHILD WITHOUT BED, INFANT (per service line). A passenger's price is fixed when the booking is confirmed and stored on the passenger. Per-passenger overrides require a reason and discount permission (PRC rules). Enforced by: Partly — create_booking prices each passenger from the group rate sheet (services taken + custom lines) and refuses a typed price on a rate-sheet group without group_pricing.edit; non-rate-sheet bookings total the per-category rates. Price is not yet frozen at confirmation and edits (PATCH) still accept a total.

PAX-031 · Refunds use what that passenger paid

Status: PROPOSED · Owner: Finance A cancellation refund is calculated from that passenger's own price under the cancellation policy — never the adult rate. An infant priced at zero is refunded zero. Enforced by (the cap): approve_passenger_cancellation() refuses a refund above the larger of the passenger's own price (booking_passenger_sold_price), their credit note and their share of the booking total. The share divides by the travellers active when the approval began: when a whole booking is approved at once (approve_booking_cancellation, LC-020), a traveller cancelled earlier in the same approval still counts, so a later traveller's share does not grow to the whole booking (20261008160000). Test: supabase/tests/cancellation_share_cap.sql.

PAX-032 · Invoices bill active passengers only

Status: PROPOSED · Owner: Finance Invoices and group invoices include only ACTIVE passengers on non-deleted bookings, and the passenger summary matches the billed lines.

PAX-033 · Room type is sold to a traveller

Status: PROPOSED · Owner: Operations · Source: GROUP RECORD 2026-27, sheet 29Aug-19D-6E-A (one sub-agent's booking carrying DOUBLE and QUAD travellers) The room type is part of what a pilgrim was sold and is recorded on the traveller, not on the booking. One booking may carry a DOUBLE couple and a QUAD family at the same time, and both are visible on the sale and the price view — not only once somebody is put in a room at the hotel-assignment stage. The booking keeps a room type as the default for passengers added to it; it never overrides what a traveller was actually sold. Where roommates in one room-share group agree on a room type, that is the capacity the rooming check uses; the room type on the hotel allotment is the fallback. Enforced by: 20260924102000_passenger_room_type.sql — BookingPassenger.roomType with the same six-value CHECK as Booking.roomType, the BookingPassenger_default_room_type BEFORE INSERT trigger, create_booking carrying a per-passenger value, and a backfill giving every existing passenger their booking's room type. analyseShareGroup() prefers what the roommates were sold. Tests: supabase/tests/passenger_room_type.sql, src/lib/api.passengerRoomType.test.ts.

PAX-034 · A traveller is only given, and only charged for, what they bought

Status: PROPOSED · Owner: Operations · Source: GRP-12AUG operations workbook — three of sixteen pilgrims booked as "FIT ROUND WAY, VISA, GROUND" A package can be bought in parts. The five per-passenger service flags (needsTicket, needsVisa, needsGroundPackage, needsHotel, needsMeals) say what a traveller bought, and they hold all the way through: the sold price, the cost, the invoice, the manifests and the readiness views. Three consequences, all of them the same rule: - A traveller is never given a service they did not buy — no room, no meal plan, no ground seat — so they never consume that stock. - A traveller is never charged for one. Each service's cost is prorated over the heads that bought it, not over the head count, so a pilgrim with no hotel carries none of the departure's rooms and none of its catering — and the margin the GST is computed on is right. - A traveller with no hotel is not incomplete. Readiness, the rooming dashboard and the journey checklist say "not required for this traveller", and a departure whose travellers all bought a part package is not "missing" a hotel. The model is opt-out: the columns are NOT NULL DEFAULT true, so a traveller with nothing said about them needs everything. Code reads the flags as !== false, never === true. Enforced by: 20260924104000_part_package_traveller.sql — assert_passenger_bought_service() triggers on BookingPassengerHotel, BookingPassengerMeal and BookingPassengerGroundService, plus group_service_headcount() / booking_service_headcount() as the one flag-aware answer used by computeBookingGroundMargin() and the booking invoice. bl_rate_sheet_price already priced per flag (PAX-030). Tests: supabase/tests/part_package_traveller.sql, src/lib/api.partPackageTraveller.test.ts.

PAX-035 · A group is managed per traveller; the group leader is a person

Status: DECIDED 2026-09-27 · Owner: Owner · Source: the owner, 27 Sep 2026 — "when we add more than one person to a booking, then in groups we don't know who to drop from the group, or who to make leader, who to transfer"; "make groups more flexible, assignments … more flexible and professional"; "do not add customer to database directly unless booking is created"; "And improve phone app for the same." A booking is how people are sold; a group is run person by person. So: - One row per traveller. A group lists every traveller under a header for the booking they are on, with their own category, passport, visa, ticket, room, meal plan and transport. Travellers who left — cancelled, or transferred to another group — stay visible under "Left the group" with the reason and the date. - The group leader is one traveller. One per group, and one of the group's own live travellers. It is not a booking (a family of four is not four leaders) and it is not the tour leader who travels with the group for the office (FLD-007). Nobody is added to the database to lead a group: someone who is not travelling yet is added through a booking first. A leader who is cancelled, transferred out, or whose booking leaves the group stops being leader. - Actions are per traveller. Make one traveller the leader; transfer one traveller to another group (LC-030, PRC-004, PRC-005); take one traveller off the group — either as a cancellation request that someone else approves, with the refund from the policy (LC-020, ACC-020), or as a transfer. Nothing is deleted. Before either, the screen says what happens to the seat, room, meals, visa and ticket. - Bulk actions work on the travellers picked, not on whole bookings: rooms, meal plans and transfers are given to exactly those travellers (PAX-034); a visa case is opened per traveller. - "Exclude group leader" on a meal plan still spares everyone on the booking the leader travels on (INV-040).

Enforced by (20261001190000_a_group_works_per_traveller.sql): TravelGroup.groupLeaderPassengerId (FK to BookingPassenger, ON DELETE SET NULL — one per group by construction), written only by set_group_leader(p_group_id, p_passenger_id) (SECURITY DEFINER; groups.edit; refuses a traveller who is not live on that group; audited group_leader_set / group_leader_cleared); a trigger refuses a browser write of the column; triggers clear it when the leader is cancelled, moves to a booking on another group, or their booking moves or is deleted. Booking.isGroupLeader is derived from it by trigger and kept only for old readers. inv_group_meal_headcount, fld_group_passengers and fld_group_manifest read the person. group_screen() returns each traveller's services and the transfers out. place_travellers() (bookings.edit) places selected travellers into one hotel, meal plan or transfer in one call. The staff app's remove and transfer run request_passenger_cancellation() (bookings.cancel) and transfer_traveller_to_group() (booking.transfer, which calls transfer_passenger_to_booking()); the desktop uses the booking page's own dialogs. Tests: supabase/tests/a_group_works_per_traveller.sql, src/components/groups/groupTravellers.test.ts, src/components/groups/GroupTravellersPanel.test.tsx, apps/mobile/src/lib/groupTravellers.test.ts. Not built: choosing an existing target booking for a transfer from the app (the app always makes a new booking); approving a cancellation from the app; linking flights and opening visa cases in one database call (the desktop still makes one request per selected traveller for those two).

PAX-036 · A family is recorded per departure, never assumed

Status: DECIDED 2026-09-28 · Owner: Owner · Source: the owner, 27–28 Sep 2026 — "sometimes more than one passenger in the booking doesn't necessarily mean they are family … give a method to map who is whose family, but it should be reliable"; "what if there are more than one family in a single booking?"; families are recorded "before departure" A booking is how people were sold; a family is who travels together as kin. They are different things: - A family is its own record on one departure — a name (e.g. "Rashid family"), a head, and its members, each with their relationship to the head chosen from a list (head, spouse, son, daughter, father, mother, brother, sister, grandparent, grandchild, son-in-law, daughter-in-law, father-in-law, mother-in-law, other relative; the list is kept by whoever holds admin.config.edit). A family may take in travellers from several bookings, even booked by different partners, but never from two departures. One booking may hold several families and people in no family. - Nothing is assumed. Every live traveller is in a family, not family (a person said they travel without family), or not recorded (the default). No family is made from surnames, from being on the same booking, or from the old free-text relationship on a traveller. Screens may suggest (same booking, same surname; "Treat this booking as one family" pre-fills relationships from the old text where it is certain) — a person confirms every family. - One family per traveller per departure. The head is always one of its members. - It keeps itself correct. A traveller who is cancelled, transferred to another departure, or whose booking moves, is cancelled, rejected or deleted leaves their family (and a "not family" decision does not follow them). If the head leaves, the family "needs a new head" — nobody is picked automatically. A family whose last member leaves is dissolved. - Recorded before departure, not a condition of approval. A booking is approved without it. Readiness before departure counts: travellers not recorded on bookings with two or more travellers (a booking with one traveller needs no family decision), minors without a travelling guardian (PAX-021), families without a head, and infants roomed apart from their guardian (PAX-012). - Who records it. Office staff with bookings.edit (every role with groups.edit holds it). A partner may record families, "not family" and guardians for the travellers of their own bookings only. Every change says who, when and — where given — why, and the history is kept.

Enforced by (20261001220000_families_per_departure.sql): tables FamilyRelationship (the list; changed only by family_relationship_save() with admin.config.edit; deactivated, never deleted; head cannot be switched off), TravelFamily (per departure; dissolved rows kept with who, when and why) and TravelFamilyMember (UNIQUE (passengerId); the head is a member by foreign key TravelFamily(id, headPassengerId) → TravelFamilyMember(familyId, passengerId), which empties the head when that member goes); BookingPassenger.familyStatus (not_recorded or not_family; "in a family" is membership, never stored). A trigger refuses a member who is not live on the family's departure, a dissolved family, or a relationship off the active list; another refuses a browser write of the traveller's family columns. Writes only through SECURITY DEFINER functions — family_save, family_remove_member, family_dissolve (a reason), traveller_set_family_status, travellers_set_family_status, traveller_set_guardian — each checking bookings.edit or the partner's ownership, each audited (family_created, family_updated with before and after, family_member_removed, family_member_left, family_needs_head, family_dissolved, family_status_set). Follow triggers on BookingPassenger (cancelled, moved, deleted) and Booking (moved, deleted, cancelled, rejected) — beside, not replacing, the PAX-035 ones. Row security: staff read the family tables with the same rights that read BookingPassenger; a partner or customer reads the families their own travellers are in; anon reads nothing. Readers: prf_booking_passengers() (booking and group screens), fld_group_manifest() (staff and tour-leader app), booking_families(), family_prefill_from_booking() (a suggestion; saves nothing), family_readiness(). Tests: supabase/tests/families_per_departure.sql, src/lib/api.families.test.ts, src/lib/families.test.ts, apps/mobile/src/lib/families.test.ts. Not built: a mahram check (PAX-022 is open); refusing to room an infant apart from the guardian (readiness flags it); moving a whole family to another departure in one step (each traveller is transferred and the family is recorded again there).