Tickets
One airline ticket for one passenger. Every seated passenger has one automatically; staff enter the ticket number the airline issues. There is nothing to sync.
Routes: /tickets (the departure list), /tickets?flight=<id> (one departure's sheet),
/tickets/:id (one ticket's detail), gated on tickets.view.
Rules: AIR §24 (ticketing), LC-001 (a confirmed booking), LC-010 / ACC-020 (maker ≠ checker), CXL-040 (ticket status follows the seat).
1. How to ticket a departure
- Open Ticketing. There is one line per departure flight: seated, issued, ready, held, and any exceptions waiting for a decision.
- Open the departure. Every seated passenger is on one sheet.
- Type numbers into the Ready rows, or press Paste from airline and paste the list the airline sends back — either one number per line in the order of the Ready rows, or a name and a number on each line (surname first, titles, with or without the dash are all read). Preview shows which number goes to which pilgrim and lists anything it could not place. It never guesses between two pilgrims with the same name.
- Press Save, say where the numbers came from, confirm. They are saved together: if any one is refused, none is saved and every problem is listed.
On the phone (staff app, Tickets → a departure, tickets.approve) the same paste is Paste from
the airline: paste, Read the list, check the preview, say where the numbers came from, and
Save sends them in one issue_tickets call, all or nothing. The phone reads the paste with
the same code (src/lib/ticketPaste.ts, copied to apps/mobile/src/lib/ticketPaste.ts and held
equal by ticketPaste.drift.test.ts). See The native app.
2. When a ticket may be entered
A passenger's row is Ready once finance has confirmed the booking — its status is
APPROVED, which the rulebook calls CONFIRMED (LC-001) — or
once an exception for that passenger has been approved by a different person. Otherwise the row
is Held, and says why.
Confirmation does not yet check payment
Finance can confirm a booking with nothing paid, because what must be paid before confirmation is still an open question (PRC-010). So a confirmed booking can be ticketed with its balance still owed. Until PRC-010 is decided, payment is chased as a receivable, not by holding the ticket. (Before 25 Sep 2026 the ticket waited for the money itself.)
This is worked out live every time the sheet opens and again when the numbers are saved. It is never stored, so there is nothing that can fall out of step.
3. There is nothing to sync
The database creates a passenger's ticket the moment operations seats them on a flight, and never for a cancelled passenger. The ticket's name is the passenger's name: correct the passenger on their booking and an unissued ticket follows. After issue, a name change is recorded against the ticket and the sheet flags it — the airline must reissue and may charge a name-change fee.
Before 25 Sep 2026 the browser created tickets itself, some failures were swallowed, and a
Sync Tickets button existed to repair them; a separate "finance clearance" step and two
name editors kept copies in step. All of that is gone. The routes that did it answer
410 Gone with what replaced them.
4. Statuses
NOT_READY · ISSUED · VOID · CANCELLATION_PENDING · REFUND_PENDING · REFUNDED
On the sheet a row shows Ready, Held, Issued or Cancelled. Ready and Held are
worked out from the booking, not stored. ISSUED, VOID, CANCELLATION_PENDING,
REFUND_PENDING and REFUNDED are final: a browser session cannot move a ticket into or out
of them.
5. Exceptions need two people
An exception lets a pilgrim be ticketed before finance confirms the booking — the GM approval of AIR §30.
| Step | Where | Permission |
|---|---|---|
| Ask | Ask for exception on a held row, with the reason | tickets.edit |
| Decide | Approve / Refuse on the row, with a reason | tickets.approve |
The person who asked sees "You asked — someone else decides" and cannot decide it; the database refuses it too. Editing the ticket row cannot approve an exception.
6. What a browser session cannot change
TicketRecord_guard refuses, from a client session: creating a ticket (the database does, from
the seat); typing a name onto a ticket (it comes from the passenger); setting the ticket number,
issuer or any exception field by editing the row; and any move into or out of a final status.
7. Cancellation
When a passenger's cancellation is approved, release_passenger_services moves the ticket
(CXL-040):
| Ticket was | Becomes |
|---|---|
| not yet issued | VOID |
| issued | CANCELLATION_PENDING → filed with the airline → REFUND_PENDING → settled → REFUNDED; or VOID if the seat is re-used |
The seat itself goes back to the block or FIT it came from, onto the released-seat register — see Holds, deadlines and releases.
8. Permissions
| Action | Permission |
|---|---|
| See tickets | tickets.view |
| Ask for an exception | tickets.edit |
| Enter ticket numbers, decide an exception | tickets.approve |
9. Issued documents
The office issues an e-ticket sheet for a booking as a PDF — Alhuda's own sheet, not the airline's ticket document, and it says so on its face (TRV-013). It carries the booking number and group code, every traveller ticketed by Alhuda (name and the last four of the passport) with their ticket number and PNR, the departure's flights (airline, flight number, route, dates and times, PNR, class and baggage where recorded), the office's contact for emergencies and a terms line. A traveller whose passenger row says they do not need a ticket is listed as "own arrangements" and does not block the sheet.
Where: the native app's booking screen, Documents → Issue e-ticket (tickets.view +
bookings.view; see The native app), and the desktop booking page's
Documents card on the Overview tab (src/components/bookings/BookingDocumentsSection.tsx):
the live files with Open, and Issue e-ticket / Re-issue e-ticket for the same pair
of permissions, confirmed first, the function's refusal shown under the buttons. The edge
function issue-document checks both permissions, gathers the booking with the service role,
renders the sheet, stores it on the company Shared Drive under Issued / the booking
(ACC-074) and records it in IssuedDocument with its Drive file id
(issue_document_record). Open shows the PDF in a viewer on the page. One live sheet per booking: a re-issue supersedes the previous row
(supersededAt); nothing is deleted. A second tap within a minute by the same person returns
the sheet just issued.
Refused, with the reason shown: any traveller ticketed by Alhuda who has no issued ticket number yet (the refusal names them — enter the numbers on the departure sheet first); a booking finance has not confirmed, or a cancelled one (LC-004); a departure with no flight recorded.
Who opens it: the booking's traveller in the app (My documents → Tickets, through
trv_my_documents() and drive-file), the booking's partner (Booking → Documents), and
staff with bookings.view. The e-ticket record on TicketRecord is unchanged: the sheet is
prepared from it, never the other way round.
10. Not built
- Ticket deadlines beyond the block's six — passport/document and check-in deadlines are not modelled (AIR §21).
- A per-seat selling price on a passenger's flight, so airline gross margin cannot be attributed per passenger (AIR §25).
- Consistency checks — name on ticket against passport, a ticket issued for a traveller with no visa close to departure (INT-130, Wave 4).
- The final passenger list locked after approval (AIR §23).
- Fare, taxes, commission and selling price on the ticket. AIR §24 asks for them; the ticket holds the number, PNR, passenger, issuer and time. Airline cost is on the block or FIT row.
- A payment rule for ticketing. See the warning in section 2.
- The airline's own e-ticket PDF. The sheet in section 9 is the company's; the airline's document is not fetched or stored. Nor is a sheet per passenger (one per booking), or a notification to the travellers when a sheet is issued.
11. Where to look
| Concern | Path |
|---|---|
Ticketing without a sync — seat trigger, gate, issue_tickets, the sheet |
supabase/migrations/20260925100000_ticketing_without_sync.sql |
| Exceptions, guard, policy (first version) | supabase/migrations/20260918130000_tickets_visa_comms.sql |
| Paste reader | src/lib/ticketPaste.ts |
| Cancellation statuses | supabase/migrations/20260917130000_cancellation_chain.sql |
| Issued e-ticket sheet (TRV-013) | supabase/functions/issue-document/index.ts, supabase/migrations/20260929120000_an_eticket_and_a_voucher_are_files.sql, apps/mobile/src/components/IssuedDocuments.tsx |
| Screens | src/pages/tickets/ |
| Tests | supabase/tests/ticketing_without_sync.sql, supabase/tests/cancellation_chain.sql, supabase/tests/an_eticket_and_a_voucher_are_files.sql, src/lib/ticketPaste.test.ts, src/lib/api.tickets.test.ts, apps/mobile/src/lib/travellerDocs.test.ts |
How the screens load
The ticketing list and a departure's sheet were already one request each
(ticketing_departures, ticketing_flight_sheet); ticking "Include departed"
now keeps the current lines on screen until the longer list arrives. A single
ticket's page loads with one request (PRF-010): ticket_screen, read
as you with tickets.view (was 12 requests, all one after another). Routes:
Operations screens API.