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:
ensureHotelSupplierRecordauto-creates aSupplierifsupplierIdisn't passed.- Compute
leaseDays = (leaseToDate - leaseFromDate). totalCostOriginal = pricePerNight × totalRooms × leaseDays.- If non-INR with
exchangeRate, convert to INR for the GL journal. - Insert
SupplierTransactionrow recording the purchase. - Post perpetual-inventory journal (idempotent — skipped if a prior journal for this hotel already exists):
- DR Stock-in-Hand (1310, ASSET) — base cost in INR
- DR GST Input Credit (1400, ASSET) — if
gstAmount > 0 - 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 postsmealRatePerDay × 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
pnrand onepricePerSeatper 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.FITInventoryhas 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.quotaBlockIdis NOT NULL and there is nofitId, soPOST /inventory/third-party-salerequires a block. - No release penalty bands.
AirlineReleasePolicyBandhas ablockIdand nofitId, so a FIT release is always free;preview_seat_release()says so inpolicy.basisrather than returning a bare zero, and works off the FIT's ownticketingDeadline. There is also no FIT option on the seat-release screen (/inventory/releases), though the route and theSeatReleasetable both accept afitId. - No
releasedUnfiledSeatsoninv_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/manifesthas no twin) and nofitentity 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.