Skip to content

Inventory API

Written April 2026 — read this first

The route tables below are still broadly right, but inventory counters are now derived by the database and an over-allocation is refused rather than clamped; blocks and FIT records are archived, not deleted; and there are new surfaces for holds, the six block deadlines, seat releases, penalty bands and drift. Ticket issue and visa status changes go through database functions. Read Holds, deadlines and seat releases, Tickets and Visa.

Hotels, airline quota blocks, FIT inventory, B2B flight offers, food, ground transfers, visa cases, and tickets.


Hotels

Handler: handleHotels at src/lib/api.ts:15385.

Route Method Permission Purpose
/hotels GET (read-only) List hotels joined to active Supplier rows
/hotels POST hotels.create Create hotel contract; posts hotel_purchase journal (DR Stock-in-Hand, CR Supplier Payable); ensures supplier record
/hotels/:id PATCH hotels.edit Update hotel fields
/hotels/:id DELETE hotels.delete Remove hotel

POST /hotels — inventory journal

Cite: src/lib/api.ts:15394.

Input — { supplierId?, city, starRating?, distanceFromHaram?, roomType, totalRooms, pricePerNight, currency, exchangeRate?, leaseFromDate, leaseToDate, gstEnabled, gstRate, gstAmount, ... }.

Work:

  1. ensureHotelSupplierRecord auto-creates a Supplier if supplierId isn't passed.
  2. Compute leaseDays = (leaseToDate - leaseFromDate).
  3. totalCostOriginal = pricePerNight × totalRooms × leaseDays.
  4. If non-INR with exchangeRate, convert to INR for the GL journal.
  5. Insert SupplierTransaction row recording the purchase.
  6. Post perpetual-inventory journal (idempotent — skipped if a prior journal for this hotel already exists):
  7. DR Stock-in-Hand (1310, ASSET) — base cost in INR
  8. DR GST Input Credit (1400, ASSET) — if gstAmount > 0
  9. CR Supplier Payable sub-ledger — gross amount in INR

Parity with airline blocks

Hotel inventory is treated as stock-at-cost until rooms are consumed by GroupHotelAssignment. The expense fires when the assignment is made, not when the contract is signed. Matches buildQuotaBlockPosting for an airline block.

The row and its money go in together

POST /hotels, POST /hotels/assignments, POST /inventory/quota-blocks and POST /inventory/fit each call one SECURITY DEFINER function — create_hotel_inventory, create_hotel_assignment, create_quota_block, create_fit_inventory — which checks the caller's operational permission (hotels.create, hotels.edit, inventory.create) and writes the row, the voucher, the payable and any initial payment in one transaction, posting as the system. Nobody needs finance.create to buy stock or allocate it, and the voucher still waits for a second person (FIN-032). A refused voucher means no row: there is no half-created state to compensate for. See FIN-030.

Hotel assignments (allocate to groups)

Handler: handleHotelAssignmentsRoute at src/lib/api.ts:15916.

Route Method Permission Purpose
/hotels/assignments GET (read-only) List all assignments
/hotels/assignments POST hotels.edit Assign rooms from a hotel to a group. The database refuses a stay without dates, outside the allotment or the departure, or over the rooms or beds free on any night (INV-031/032)
/hotels/assignments/:id PATCH hotels.edit Update assignment
/hotels/assignments/:id DELETE Remove assignment

Hotel low-stock thresholds

Handler: handleHotelThresholds at src/lib/api.ts.

Route Method Permission Purpose
/hotels/thresholds/hotels POST hotels.edit Set global low-room threshold

(The food threshold endpoint has moved — see "Food inventory" below.)


Food inventory

Food was split out of the Hotels page in 2026-04 and now lives on its own routes with its own permission family (food.*). The legacy /hotels/food/* paths remain as aliases for one release while clients migrate.

Handler: handleFoodInventory at src/lib/api.ts.

Route Method Permission Purpose
/food GET (read-only) List food items
/food POST food.create Create food inventory item
/food/:id PATCH food.edit Update item
/food/:id DELETE food.delete Remove item
/food/import POST food.create Bulk import food items (JSON)
/food/import-file POST food.create Bulk import from uploaded file
/food/thresholds POST food.edit Set global low-stock threshold

Legacy aliases (deprecated, kept for one release): /hotels/food, /hotels/food/:id, /hotels/food/import, /hotels/food/import-file, /hotels/thresholds/food. Same behaviour, same permissions — clients should migrate to the /food/* paths.

The contract exchange rate

A catering contract signed in riyals carries the rate it was signed at. POST /food and PATCH /food/:id accept exchangeRate, GET /food returns it, and the purchase voucher (Dr Stock-in-Hand 1310 / Cr the caterer's payable) is posted in rupees at that rate with the riyal figure kept on each line (originalDebit / originalCurrency / fxRate). The meal-day consumption entry uses the same rate, and so does the group cost report (FIN-034).

A non-INR contract always carries a rate. Leave exchangeRate out of POST /food and the day's rate is resolved from Finance → Settings → Exchange Rate Management; if there is none the contract is refused rather than created without one (FIN-034). A contract that reaches the database without a rate posts nothing — never riyals as rupees — and the refusal sits in Unposted entries; setting the rate on the contract posts every meal plan on it.

POST /food requires supplierId, pricePerMealDay and capacityPerDay: a contract is a purchase, and it writes the caterer's bill (SupplierTransaction, type: debit) beside the Stock-in-Hand voucher, so the supplier ledger shows what is owed. The CSV importers (/food/import, /food/import-file) apply the same rules and refuse the row otherwise.

GET /food returns totalQuantity (meal-days bought), allocatedQuantity (meal-days drawn) and oversoldMealDays beside quantity (meal-days free). PATCH /food/:id takes totalQuantity; sending quantity is refused — it is the derived free stock, and writing it back as the purchase is how a contract shrank on every save (INV-042).

Food assignments

Handler: handleFoodAssignments at src/lib/api.ts.

Route Method Permission Purpose
/food/assignments GET (read-only) List meal assignments. ?groupId= narrows to one departure (it used to be dropped)
/food/assignments/headcount/:groupId GET (read-only) How many travellers a departure feeds and bills
/food/assignments POST food.edit Link a meal plan to a departure. Without quantity the plan follows the manifest; with one it is fixed (INV-042)
/food/assignments/:id PATCH food.edit Correct a plan: mealDays, mealRatePerDay, mealCurrency, dates, excludeChildren, excludeGroupLeader, notes, headcountMode, or a typed quantity (which fixes it). Stock and the cost voucher follow in the database
/food/assignments/:id DELETE food.edit Remove assignment (reverses its cost voucher)

The plan follows the manifest (INV-042)

A plan carries headcountMode. manifest (the default) means quantity (fed) and chargeableQuantity (billed) are derived by the database from the departure's live passenger list and re-derived whenever a pilgrim joins, cancels, transfers, opts out or changes category — moving the contract's stock and reversing-and-reposting the cost voucher with them. fixed means the operator typed the head-count and it stays. The unit of the contract is meal-days: a plan draws quantity × mealDays, and mealDays is never nought (taken from the dates when not sent). The cost voucher is posted by the database, not by the caller; the route's response carries posting saying what happened, and a refusal (closed period, missing head, no rate) is queued in Unposted entries with the head-counts on it rather than blocking the plan.

Legacy aliases: /hotels/food/assignments, /hotels/food/assignments/:id.

Two head-counts: fed and billed

Catering is bought at a rate per pilgrim per day (INV-040), so an assignment carries two numbers:

  • quantity — travellers fed. This is what the caterer cooks for, what draws the contract down (recompute_food_counters()), and the capacity the per-passenger meal route checks.
  • chargeableQuantity — travellers billed. The Stock-in-Hand (1310) → Food Expense (5300) consumption journal posts mealRatePerDay × mealDays × chargeableQuantity, and the group cost report reads the same number.

POST /food/assignments derives both. Leave quantity out (or send 0) and it counts the departure's active, meal-taking travellers; send a positive number to override the head-count. chargeableQuantity is never sent by the client — it is that count less the children and infants when excludeChildren is set and less everyone on the group leader's booking when excludeGroupLeader is set, each person spared once. It is never negative and never above quantity. Nothing defaults to 1: a departure with no travellers assigns 0 and posts nothing.

GET /food/assignments returns chargeableQuantity, falling back to quantity for rows written before the split.

GET /food/assignments/headcount/:groupId is what the two assign dialogs read before anyone saves. It returns { groupId, fed, chargeable, children, groupLeaders }, where chargeable is the count with both exclusions applied; with one box ticked a dialog takes just children or groupLeaders off fed.

GET /groups/:id/exports/meal-count is unaffected: children and the group leader eat, so they stay on the list the caterer gets.


Ground transfers

Handlers: handleGroundTransferInventory at src/lib/api.ts:16348, handleGroundTransferAssignments at src/lib/api.ts:16460.

Route Method Permission Purpose
/inventory/ground-transfers GET (read-only) List
/inventory/ground-transfers POST inventory.create Create transfer offering
/inventory/ground-transfers/:id PATCH, DELETE inventory.edit CRUD
/inventory/ground-transfers/:id/assign POST inventory.edit Assign to a group
/ground-transfers/assignments GET (read-only) List assignments
/ground-transfers/assignments POST inventory.edit Create assignment
/ground-transfers/assignments/:id PATCH, DELETE inventory.edit Update / remove

Ground is not perpetual inventory. Buying a vehicle posts nothing; the cost becomes an expense when the vehicle is assigned to a departure (Dr Ground Transport Expenses 5400 / Cr the operator's payable), for the quantity assigned. Capacity bought and never assigned is therefore never owed for and never costed — unlike a hotel or a meal contract, which are booked to Stock-in-Hand at purchase.

The assignment posts in rupees, converting at the vehicle's own exchangeRate when it is priced in a foreign currency, with the native figure kept on each line (originalDebit / originalCurrency / fxRate) — the same rate the group cost report converts the row with (FIN-034). An assignment whose currency differs from the vehicle's has no rate of its own and is converted at the vehicle's.


Airlines

Handler: handleInventoryAirlines at src/lib/api.ts:18049.

Route Method Permission Purpose
/inventory/airlines GET (read-only) List airlines
/inventory/airlines POST inventory.create Create airline (code, name, flightNumbers[])
/inventory/airlines/:id PATCH inventory.edit Update
/inventory/airlines/:id DELETE inventory.edit Hard-delete

Airline quota blocks

Handler: handleInventoryQuotaBlocks at src/lib/api.ts:18103.

Route Method Permission Purpose
/inventory/quota-blocks GET (read-only) List quota blocks with live seat math
/inventory/quota-blocks POST inventory.create Create block; posts DR Stock-in-Hand / CR Supplier Payable for the paid seats. With draft: true it saves the body as a draft instead (create_quota_block_draft): nothing is checked beyond the permission and nothing is posted (AIR §36); answers the draft {id, payload, status: 'draft', …}. The desktop screen uses the draft routes; the one-shot remains for old callers
/inventory/quota-blocks/drafts GET inventory.create or inventory.edit The open drafts, newest edit first: {id, payload, status, createdBy, createdAt, updatedBy, updatedAt, lastSubmitError, lastSubmitAt}[]. A draft is in no other list
/inventory/quota-blocks/drafts/:id PATCH inventory.create or inventory.edit Correct a draft (update_quota_block_draft). The body's keys are merged into the draft; a key sent as null is cleared. Every field may change, cost and legs included. Refused once submitted
/inventory/quota-blocks/drafts/:id DELETE inventory.create or inventory.edit Discard a draft (discard_quota_block_draft); body {reason}. Audited with the discarded form. Refused once submitted
/inventory/quota-blocks/drafts/:id/preview GET inventory.create or inventory.edit What the submit would post (quota_block_draft_preview): {problems[], canSubmit, totalSeats, focSeats, paidSeats, currency, rate, baseInr, gstInr, totalInr, supplierName, lines[{side, account, amountInr}], deposit}. Writes nothing
/inventory/quota-blocks/drafts/:id/submit POST inventory.create The final submit (submit_quota_block); body {block?, reason?} — block, when given, is merged into the draft first. Creates the block with the draft's id, posts the purchase voucher and records the deposit in one transaction; answers the block row with posting, deposit, submitted: true. A refusal answers 422 {message, problems, draftId} and leaves the draft with the reason. Submitting a submitted draft answers the block with alreadySubmitted: true
/inventory/quota-blocks/:id GET inventory.view One block, with focPosition and written-off seats
/inventory/quota-blocks/:id PATCH inventory.edit Update
/inventory/quota-blocks/:id DELETE inventory.edit Delete (reverses journals)
/inventory/quota-blocks/:id/manifest GET (read-only) Passenger manifest: booked pilgrims plus, per live third-party sale, the names the buyer gave us. Cancelled sales and cancelled resales are left out (INV-014)
/inventory/quota-blocks/:id/b2b-passengers POST inventory.edit Replace the names on one third-party sale (offerId, passengers[]). The sale must be live and on this block, and at most seatsTotal names are accepted; written by b2b_set_manifest in one transaction (INV-015)
/inventory/quota-blocks/:id/finance-events GET finance.view Finance event history — every ledger entry against the block, the supplier transaction that went with it, and any unsold-seat write-off (INV-025)
/inventory/quota-blocks/:id/finance-events POST finance.create; for eventType: supplier_payment also inventory.block_payments.record Supplier payment, initial-payment adjustment or reversal, airline cancellation filing, full cancellation. A supplier_payment by a caller without finance.create who holds inventory.block_payments.record is recorded by record_airline_block_payment in the database (pending, guarded, TDS-aware — FIN-044) and answers {ok, journalEntryId, supplierTransactionId, voucherNo, status, alreadyRecorded, amount, currency, amountInr, outstandingAfter, paidSoFarAfter, memo, tds}; the body may add paymentMethod and reference
/inventory/quota-blocks/:id/airline-refund POST inventory.block_payments.record A refund received from the airline against the block (FIN-044, amended 2026-09-25). Body {amount, currency, receivedIntoAccountId, method?, reference?, receivedOn (YYYY-MM-DD), notes?}; 400 without a positive amount or an account. Calls record_airline_block_refund, the native app's own function: Dr the account / Cr the supplier's SUP- ledger, the supplier_refund mirror and the supplier's refund row, pending finance (FIN-032); refused above what was paid so far, on a future date or on an archived block; the same refund twice within ten minutes returns the first. Answers {ok, journalEntryId, supplierTransactionId, voucherNo, status, alreadyRecorded, amount, currency, amountInr, paidSoFarAfter, outstandingAfter}. The web Record refund from the airline button. A FIT uses POST /inventory/fit/:id/airline-refund
/inventory/paid-from-accounts GET inventory.block_payments.record or finance.view The ledgers a payment to the airline may leave from — active leaf accounts under Current Assets, banks and cash first (airline_block_paid_from_accounts): [{id, code, name, currency, groupCode, groupName, allowDebit, allowNegativeBalance}]
/inventory/quota-blocks/:id/write-off-unsold POST inventory.writeoff.approve Write off the seats nobody took (FIN-037)

The native app does not go through these routes. It creates a block with create_quota_block_and_post, which derives the purchase voucher from the row (post_quota_block_purchase_from_block) instead of taking it from the caller and records a deposit given with it in the same transaction; records a payment to the airline with record_airline_block_payment and a refund from it with record_airline_block_refund (the website calls the same function for a block through POST /inventory/quota-blocks/:id/airline-refund and for a FIT through POST /inventory/fit/:id/airline-refund); sells seats on with sell_block_seats_to_third_party and files a sale's cancellation with file_b2b_cancellation — see Field → Functions the native app calls and Airline blocks → In the native app.

Validation rules

Cite: src/lib/api.ts:18103-18520. Cross-leg chronology is validated: arrival time cannot precede departure, return departure cannot precede outbound arrival. Invalid sequences throw 400 with a specific error message.

Complimentary seats (focSeats)

POST and PATCH take focSeats (a whole number, at most totalSeats) and focTreatment (spread — the default — or margin). A block is bought as the airline sells it: "20 seats, 2 F.O.C" is totalSeats: 20, focSeats: 2. All 20 are sellable; 18 are payable, and the purchase voucher debits Stock-in-Hand with 18 × fare. A posting that bills the complimentary seats is refused by create_quota_block() with 400; the same applies to POST /inventory/fit. GET /inventory/quota-blocks/:id returns focPosition — paidSeats, payableInr, payableIfFocBilledInr, focSavingInr, costPerSeatInr — and POST /inventory/quota-blocks/:id/write-off-unsold charges only the seats the airline was paid for, returning chargeableSeats and focUnsoldSeats alongside the amount. See AIR §15.


FIT inventory (free individual traveller)

Handler: handleInventoryFIT at src/lib/api.ts:19346.

Route Method Permission Purpose
/inventory/fit GET (read-only) List FIT rows, with allocatedSeats, availableSeats, writtenOffSeats, focSeats and focTreatment
/inventory/fit POST inventory.create Create FIT inventory; posts financial journal for the paid seats (focSeats, focTreatment)
/inventory/fit/:id GET inventory.view One FIT, with focPosition and written-off seats
/inventory/fit/:id PATCH inventory.edit Update
/inventory/fit/:id DELETE inventory.edit Delete — refused (409) while any GroupFlight row points at it, or when it carries finance records
/inventory/fit/:id/payments GET (read-only) Linked payments
/inventory/fit/:id/finance-events GET finance.view Finance event history
/inventory/fit/:id/finance-events POST finance.create supplier_payment, initial_payment_adjustment, initial_payment_reversal, cancellation (airline cancellation filing) or full_cancellation
/inventory/fit/:id/payment-summary GET inventory.view What is paid and owed on the FIT (airline_block_payment_summary, p_kind: 'fit'): {kind, id, label, currency, exchangeRate, supplierId, supplierName, archived, cancelled, paidSoFar, outstanding, paidApproved, awaitingApproval, outstandingApproved, paymentStatus, refunds, payments[]}. paidSoFar counts everything recorded and not rejected; it is the refund cap. The FIT payment dialog reads it to decide whether to show Record refund from the airline
/inventory/fit/:id/airline-refund POST inventory.block_payments.record A refund received from the airline against the FIT (FIN-044). The same body, checks and answer as POST /inventory/quota-blocks/:id/airline-refund; calls record_airline_block_refund with p_kind: 'fit', pending finance (FIN-032). The web FIT Record refund from the airline button
/inventory/fit/:id/write-off-unsold POST inventory.writeoff.approve Write off the seats nobody took (FIN-037)
/inventory/fit/:id/archive | /restore POST inventory.edit Archive or restore (AIR §26)

An individual seat is answered the same way a block seat is — see INV-025. The four gaps this page used to list are closed: the list route returns the counters the database maintains and refuses an over-allocation on (INV-012), the single read exists, the write-off exists and shares the block's implementation, and the three missing finance events post the journals a block posts.

Still not built, deliberately named here so the silence does not read as working:

  • One pnr and one pricePerSeat per FIT row. Three tickets bought separately at their own fares can only be entered as N identical seats under the first PNR, and a total price cannot be entered at all — ₹1,98,122 over 3 seats is stored as 3 × ₹66,040.67. This is a schema limit, not a route limit.
  • No legs / returnLegs. FITInventory has no column for them, so a multi-stop individual ticket is shown as one outbound and one return sector and loses its middle ones. A block has both columns.
  • No partner (B2B) sale of a FIT seat. B2BFlightOffer.quotaBlockId is NOT NULL and there is no fitId, so POST /inventory/third-party-sale requires a block.
  • No release penalty bands. AirlineReleasePolicyBand has a blockId and no fitId, so a FIT release is always free; preview_seat_release() says so in policy.basis rather than returning a bare zero, and works off the FIT's own ticketingDeadline. There is also no FIT option on the seat-release screen (/inventory/releases), though the route and the SeatRelease table both accept a fitId.
  • No releasedUnfiledSeats on inv_fit_counter_truth(), so the drift checker can report a FIT's allocation drift but not its released-but-unfiled seats.
  • No FIT passenger manifest (/inventory/quota-blocks/:id/manifest has no twin) and no fit entity type in the cancellation summary report, so a cancelled FIT does not appear in it.
  • No FIT airline-cancellation screen, though GET /finance/airline-cancellations?fitId= works.

Flight schedule lookup (edge function flight-lookup)

Fetch flight details on the Airline blocks and FIT forms calls the edge function flight-lookup through lookupFlight() in src/lib/flightLookup.ts. It is not a route in api.ts. The function holds the AirLabs key as the secret AIRLABS_API_KEY; the browser holds no key and never calls AirLabs (PLT-015).

Request Permission Answer
POST /functions/v1/flight-lookup {airlineCode, flightNo} signed-in staff with inventory.view (checked in the function; portal logins refused) 200 {schedule: {origin, destination, departTime, arriveTime, arriveDayOffset, depTerminal, arrTerminal} \| null} — null when AirLabs does not know the flight

The airline code is upper-cased and must be two or three letters or digits; the flight number keeps its digits only and must be one to five of them (400 otherwise). 405 for anything but POST, 401/403 for a caller who may not use it, 502 when AirLabs refuses or does not answer, 503 {notSwitchedOn: true} when AIRLABS_API_KEY is not set. Answers are kept in memory for ten minutes per flight number; the browser keeps its own 24-hour copy in localStorage. On any error the browser gets null and the form fills from flights already in inventory, as it does for a flight AirLabs does not know. The key is never logged or returned.


B2B flight offers (partner-sold seats)

Handler: handleInventoryB2BFlights at src/lib/api.ts:26930.

Route Method Permission Purpose
/inventory/b2b-flights GET (read-only) List offers open for partner sale
/inventory/b2b-flights POST inventory.create Create offer (seatsTotal, seatsAvailable, pricePerSeat)
/inventory/b2b-flights/:id PATCH inventory.edit Update price, currency, notes, group link, or open/close. status: CANCELLED is refused: a sale is cancelled through the B2B cancellation filing (INV-014)
/inventory/b2b-flights/:id DELETE Delete

Partner bookings reduce seatsAvailable atomically (matched-by-version UPDATE in handleSalesBookings POST) — see Bookings.


Inventory reports

Route Method Handler Purpose
/inventory/pnl GET handleInventoryPnl Per-block/hotel P&L
/inventory/utilization GET handleInventoryUtilization Seat / room utilization
/inventory/status GET handleInventoryStatus Overall inventory status
/inventory/outside-trip-dates?groupId= GET inventory_outside_trip_dates Every hotel stay, meal plan, transfer and flight outside its departure, or outside its allotment or contract (INV-003/032). groups.view or inventory.view

All read-only.

GET /inventory/party-accounts

The buyer picker for a third-party seat sale. Returns the active party ledgers — every ledger under Sundry Debtors, Sundry Creditors and Partner Receivables — as { id, code, name, type, parentId, parentCode }, nothing else (no balances, no bank or cash accounts). Needs inventory.edit, so the ticket manager who owns the block can name the buyer without finance.view; the database function inv_party_accounts() also admits finance.view (20260929160000, supabase/tests/a_seat_seller_sees_the_parties.sql, INV-014).

POST /inventory/third-party-sale

Handler: handleThirdPartySale in src/lib/api.ts. Permission: inventory.edit. Sells seats off a block to another agency: {quotaBlockId, seats, marginPerSeat, currency?, notes?, buyerAccountId?, gstEnabled?, gstRate?, lossReason?}. Carves the seats (b2b_transfer_seats), posts the revenue voucher (Dr the buyer's ledger — a partner's AGR- receivable when buyerAccountId is one, else the account chosen, else Bank; Cr Sales for the cost, Commission Earned for the net margin, GST Payable on it — the margin entered is GST-inclusive) and the COGS voucher through the operational door, mirrors a partner's statement line, and raises the buyer's invoice. Refuses more seats than are open, a negative selling price, and a loss with no lossReason. The native app calls sell_block_seats_to_third_party, which does the same in one transaction (INV-014); the buyer picker on both reads GET /inventory/party-accounts / inv_party_accounts(). A sale is cancelled through the filing, POST /finance/b2b-cancellations (Finance): finance.create, or inventory.b2b_cancellations.file, which the route sends through file_b2b_cancellation in the database; finance decides either way.


Phone app — the Operations tab (database functions)

The native app has no routes of its own; it calls the database through the Supabase client (UX-024).

app_operations_summary() — 20261008235000_app_operations.sql

No arguments. Read only. Refused (42501) with nobody signed in, or for a login without inventory.view, hotels.view, food.view, tickets.view or finance.view. Returns one object; a section the caller may not read is absent, one that failed is null and its name is in errors:

Key Permission Shape
airline inventory.view { blocks, unsold } — not archived, not cancelled, not yet flown; unsold from availableSeats
ground inventory.view { transfers, free } — departing today or later, or with no time
holds inventory.view { active, expiringSoon, pastExpiry } — expiringSoon ends within 24 hours
releases inventory.view, tickets.view or finance.view { waiting, toFile } — pending_approval and approved
deadlines inventory.view or tickets.view { count, overdue, next } — from dash_deadline_radar(7, 50)'s items, i.e. only the deadlines nobody has acknowledged (at most 50); overdue those dated before today; next the first
hotels hotels.view { blocks, roomsFree, cities: [{ city, blocks }] } — held today or later, live supplier; cities lower-case, Makkah then Madinah then by name
food food.view { contracts, over } — running today or later, or with no end date
errors, at — the failed sections; when it was read

The Operations screens also call, directly and with the same arguments as the website's routes: dash_deadline_radar, acknowledge_inventory_deadline_alert, fire_inventory_deadline_alerts and escalate_inventory_deadline_alerts (the website's /inventory/deadline-radar, /inventory/alerts/:id/acknowledge, /inventory/alerts/fire), and create_inventory_hold, extend_inventory_hold, convert_inventory_hold, release_inventory_hold and inventory_resource_position (the website's /inventory/holds routes). Each checks its own permission. extend_inventory_hold is replaced in the same migration: it refuses a hold already past its end ("This hold has expired; make a new hold so the seats are checked again") and a new end that is not in the future (INV-012). The phone does not write the website's extra logAudit row; the audit trigger on InventoryHold and InventoryDeadlineAlert records the change.

Phone app — a departure's flights (database functions)

20261009100000_app_group_operations.sql (INV-033). The phone's Group → Flights calls these instead of writing GroupFlight as the website's POST, PATCH and DELETE /groups/:id/flights do. Each refuses (42501) with nobody signed in or without groups.edit, and writes an AuditLog row (entity group, source db_function, metadata.via app). The anonymous key may call none of them.

Function Does Refuses
app_link_group_flight(p_group_id, p_block_id, p_fit_id, p_pnr) Links exactly one block or FIT with an optional PNR (trimmed). Answers the GroupFlight row with alreadyLinked; a second call for the same departure answers the existing row (alreadyLinked: true). Audit flight_linked both or neither id (22023); held by another departure, named (23514); a block with fewer availableSeats than the departure's travellers who need a seat — not cancelled, not an infant, needsTicket not false (23514, INV-012); outside the departure's dates (the GroupFlight_date_guard trigger, INV-032); a draft block (its own trigger)
app_set_group_flight_pnr(p_group_flight_id, p_pnr) Sets the PNR; blank clears it. Answers the row with changed. Audit pnr_updated only when it changed a PNR over 20 characters (22023); an unknown flight (P0002)
app_unlink_group_flight(p_group_flight_id) Deletes the link. Answers { id, groupId, alreadyGone }; an id already gone answers alreadyGone: true. Audit flight_unlinked a traveller on the flight (23514, "1 traveller is on this flight…")

The phone's seat and room numbers need no function: they are updates of BookingPassengerFlight.seatNumber and BookingPassengerHotel.roomNumber under the row policies bpf_update and bph_update (bookings.edit), the same writes as the website's per-traveller PATCH routes.

Phone app — seat offers to partners (database functions)

20261009120000_app_inventory.sql (INV-016). The phone's Operations → Partner offers calls these instead of the website's POST /inventory/b2b-flights (which calls b2b_transfer_seats directly) and PATCH /inventory/b2b-flights/:id (which writes the status from the browser). Each refuses (42501) with nobody signed in or without its permission, and writes an AuditLog row (entity b2b_flight_offer, source db_function, metadata.via app). The anonymous key may call neither.

Function Permission Does Refuses
app_open_b2b_flight_offer(p_block_id, p_seats, p_price, p_currency, p_notes, p_request_id) inventory.create Carves the seats through b2b_transfer_seats, the website's function, so the offer and the block's counters are written as from the website. The currency defaults to the block's. Answers the B2BFlightOffer row with alreadyOpened; the same p_request_id from the same person again answers the first offer (alreadyOpened: true) and carves nothing. Audit b2b_offer_opened (seats, price, currency, request id) no seats or a negative price, a currency that is not three letters (22023); an unknown block (P0002); an archived block, a block cancelled with the airline, a block that has flown, and more seats than block_available_seats() — free seats less those held or waiting for release (23514, "AIN-1 has 5 seats free to offer (held seats and releases waiting are not counted); 6 asked for", INV-011, INV-012)
app_close_b2b_flight_offer(p_offer_id, p_reason) inventory.edit Sets the offer CLOSED: partners can no longer buy from it; its seats stay with it. Answers the row with alreadyClosed; a closed offer answers alreadyClosed: true. Audit b2b_offer_closed with the reason and the old and new status a reason under three characters (22023); an unknown offer (P0002); a cancelled offer, an offer carved for a departure, and an offer sold to a partner — named, "Cancel the sale through Cancel this sale instead" (23514, PTR-041, INV-014)

The rest of step 5 calls the website's own functions directly: assign_hotel_rooms and unassign_hotel_rooms from a hotel block (hotels.edit), place_travellers from a hotel block's departure (bookings.edit), record_airline_block_payment from a supplier's block or FIT (inventory.block_payments.record), and issue_tickets with a whole pasted list (tickets.approve, all or nothing — the website's POST /tickets/flights/:id/issue). The hotel's travellers and bed offers are table reads under row security, as GET /hotels/:id/passengers and the hotel dashboard read them.

Visa

Handler: handleVisaRoute at src/lib/api.ts:13975.

Route Method Permission Purpose
/visa GET (read-only) List visa cases
/visa POST visa.create Create visa case for a booking passenger
/visa/sync POST visa.edit Reconcile per-passenger cases with booking manifests; clean up legacy per-booking cases
/visa/:id GET (read-only) One case + documents + status history + enriched passenger data
/visa/:id/status PATCH visa.edit Change status through change_visa_status (VISA-003 steps only, reason required). On ISSUED / REJECTED the database queues the customer's and the partner's e-mail (VISA-005); the handler only kicks the dispatcher. Answers {ok, status, changed, customerNotified} — customerNotified is true only when the customer's e-mail was queued by this call
/visa/:id/record-outcome POST visa.edit Record an end state (ISSUED/COLLECTED/REJECTED) as at a date, with a source — one transition, marked a recorded historical fact, only on a case still at NOT_STARTED. No customer email (VISA-004)
/visa/:id/upload POST tickets.edit ( ) Upload a visa document
/visa/:id/documents GET (read-only) All visa + customer documents merged
/visa/:id/documents/:docId DELETE tickets.edit ( ) Remove a document
/visa/supplier-bills POST finance.create Bill a set of finished visa cases to the agent who processed them: {caseIds[], supplierId, unitPrice, currency, invoiceNo?, invoiceDate?, notes?} → {billId, cases, unitPrice, total, currency, totalINR, supplierName, entryIds}. Refuses a case still with the embassy and a case already billed (FIN-043)

The native app calls the database directly for visa work (no api.ts route):

Function Permission Purpose
visa_phone_cases(p_q, p_filter, p_group_id, p_limit) visa.view (SECURITY INVOKER, row security) The phone list: p_filter moving / issued / rejected / all, a travel group, a search over traveller, passport (normalised), application, booking, group code and customer code. Returns {rows, groups} — each row with documents, hasVisaCopy, customerCode and its booking and group; groups the departures with visas still moving (VISA-032)
visa_case_screen(p_id) visa.view One case with its documents and history (also GET /screens/visa/:id)
change_visa_status(p_case_id, p_to_status, p_reason) visa.edit As PATCH /visa/:id/status. Answers changed, customerNotified and notification (emailsQueued, customerEmailQueued, partnerEmailQueued, customerNotQueuedBecause: opted_out / no_email / no_customer / already_queued)
add_visa_document(p_case_id, p_drive_file_id, p_file_type) visa.edit Records a file drive-upload stored for the case (kind visa_doc) as a VisaDocument (VISA_PDF, PASSPORT_SCAN, APPROVAL_EMAIL, OTHER). Refuses a file stored for another case or kind; a second call returns the same row with alreadyRecorded: true (VISA-031)

Auto-created on ops-approve

When POST /operations/bookings/:id/ops-approve fires, the handler auto-creates a VisaCase for every passenger who needs a visa (the service flags are an opt-out model: needsVisa !== false — PAX-034) and emits a visa_status_change email. See Bookings.


Tickets

Handlers: handleTickets at src/lib/api.ts:15215, handleTicketById at src/lib/api.ts:14920.

Route Method Permission Purpose
/tickets/departures GET tickets.view (database) One line per departure flight: seated, issued, ready, held, exceptions asked. ?includePast=1 includes departed ones. Calls ticketing_departures()
/tickets/flights/:groupFlightId GET tickets.view (database) The sheet: every seated passenger with their ticket, state (issued / ready / held / cancelled) and why a row is held. Calls ticketing_flight_sheet()
/tickets/flights/:groupFlightId/issue POST tickets.approve { entries: [{ passengerId, ticketNumber }], reason, pnr? } — saves a whole departure in one call, all or nothing; a refusal lists every row that needs fixing. Calls issue_tickets()
/tickets GET tickets.view List ticket records with booking/customer/group flight snapshot
/tickets/:id GET tickets.view One ticket with full context. financeClearanceStatus is worked out live: cleared when finance has confirmed the booking, exception when one was approved, else pending
/tickets/:id PATCH tickets.edit Generic field update (never status, number, issuer or name)
/tickets/:id/issue POST tickets.approve { ticketNumber, reason, pnr? } — one ticket, through the same issue_tickets() call on the passenger's own seat
/tickets/:id/request-exception POST tickets.edit Ask to ticket before finance confirms, with the reason
/tickets/:id/approve-exception, /reject-exception POST tickets.approve Decide an exception; never by the person who asked (LC-010)
/tickets/:id/documents POST tickets.edit Attach TicketDocument
/tickets POST — Retired: answers 410. Tickets are created by the database from the seat
/tickets/:id/name-update PATCH — Retired: answers 410. The name comes from the passenger
/tickets/:id/finance-clear POST — Retired: answers 410. Readiness is worked out live

When a ticket may be issued

A ticket may be issued once finance has confirmed the booking (APPROVED — LC-001), or once an exception for that passenger was approved by a different person. ticketing_confirmed() in the database decides it; isTicketingConfirmed() in src/lib/api.ts mirrors it for display only. Confirmation does not check payment while PRC-010 is open. See Tickets.