Skip to content

Airline Blocks

Written April 2026 — read this first

The block model, legs and PNR handling below are still right. Three things are no longer: seat counters are derived from the allocation records and an over-allocation is refused rather than clamped to zero; a block with anything hanging off it cannot be deleted, only archived; and focSeats (complimentary seats) is now read, so the payable no longer bills them. One expiryDate has become six contractual deadlines. Read Holds, deadlines and seat releases and 04 · Inventory and groups.

Airline-block inventory — bulk pre-purchased seats on scheduled flights, held as stock on the books until they are consumed by a group booking or sold to a B2B partner. This is the flight-side counterpart to the Hotels module.

What is an airline block?

An airline block (internally AirlineQuotaBlock) is a bulk seat purchase made directly from an airline at a fixed PNR — typically a negotiated group fare. Each block sits on the balance sheet as stock (Dr Stock-in-Hand / Cr Supplier Payable) at creation. Seats are moved to Purchase A/c via COGS when they go out — either to a group booking (GroupFlight link) or to a B2B partner sale (B2BFlightOffer). Source: the in-app HelpBanner in src/pages/inventory/AirlineBlocks.tsx (line ~2348).

A block starts as a draft

AIR §36, owner 26 Sep 2026. New block opens the form with two buttons:

  • Save draft keeps the form as it is, half filled or not. Nothing is posted — no journal, no ledger line, no supplier transaction, no deposit. The draft is not a block: it is in no list, picker, dashboard or report, and it cannot be linked to a group, held, released or sold. inventory.create saves a new draft; inventory.create or inventory.edit corrects one.
  • Submit block (inventory.create) runs the form's checks, saves the draft as it stands, and opens the review: the airline and flight, the route, the PNR, the seats (complimentary and paid), the purchase voucher's Dr/Cr lines with amounts, and the deposit voucher when there is one. The lines come from the database (quota_block_draft_preview), and anything the database would still refuse is listed and blocks the Yes. A deposit asks for a reason (UX-001). Yes, submit block creates the block with the draft's id, posts the purchase voucher and records the deposit, in one transaction, pending finance (FIN-030, FIN-044). No, keep as draft leaves it a draft. A refused submit leaves it a draft, with the reason shown on the drafts list.

Drafts (next to New block, for inventory.create or inventory.edit) replaces the block list with the open drafts: airline, first flight, PNR, route, seats and cost, last edited, and the last refusal. Each has Edit (the form again), Submit (inventory.create) and Discard (a reason; the audit trail keeps the discarded form). A submitted draft leaves the list; its block is edited, re-costed or archived like any other.

Routes: POST /inventory/quota-blocks with draft: true, GET /inventory/quota-blocks/drafts, PATCH / DELETE /inventory/quota-blocks/drafts/:id, GET …/drafts/:id/preview, POST …/drafts/:id/submit (API → Inventory).

Not built: complimentary seats, the contract exchange rate and the six deadline columns are not on the desktop's create form. Complimentary seats and the deadlines are set after the submit under Manage. Without a rate field a foreign-currency block cannot be submitted from the desktop — it never could be created there — and the review says so (FIN-034); the app's form has the rate, and a draft saved on the desktop can be opened and finished in the app.

What it manages

  • Blocks — airline + PNR + trip type (one_way / return / multi_city) + totalSeats / allocatedSeats / availableSeats + price/seat + currency + expiry date + status.
  • Legs — structured flight segments stored as JSON in AirlineQuotaBlock.legs and AirlineQuotaBlock.returnLegs (added in supabase/migrations/20260330120000_quota_block_legs.sql). Each leg carries origin, destination, terminal, arrivalTerminal, flightNo, departDate, departTime, arriveDate, arriveTime, and an optional per-leg pnr.
  • B2B seat sales — the page includes a "Sell Seats" flow that creates B2BFlightOffer rows against a block, lowering availableSeats and creating an invoice on the partner ledger. A sale is cancelled through Cancel Sale (the B2B cancellation filing), which returns the seats and posts the money; a cancelled sale holds no seats (INV-014).
  • Manifest names for sold seats — under each live third-party sale in the block drawer, Add Passengers / Edit Passengers records the names the buyer gave us: at most one per seat, replaced in one save, gone when the sale goes (INV-015). A name is either a booked pilgrim or a buyer's name for a sold seat; there is no "manual passenger" outside a sale.
  • Supplier payments / finance events — recorded against the airline supplier via postAirlineBlockFinanceEvent in src/services/airlineBlockService.ts.
  • FIT inventory — the related FIT (Free Individual Traveller) table lives on a sibling page at src/pages/inventory/FITInventory.tsx for individually-sold tickets rather than bulk blocks. It answers the same questions this page does — open seats from the counters the database maintains, the departures using it read off GroupFlight, bookings sold, revenue and outstanding, supplier payments and adjustments, and the unsold-seat write-off. See INV-025 for what an individual seat still cannot express (one PNR and one fare per row, no multi-stop legs, no partner sale, no release penalty bands).
  • Ground transfers — another sibling inventory type on src/pages/inventory/GroundTransfers.tsx.

Pages

  • src/pages/inventory/AirlineBlocks.tsx — the primary page. Both list/detail modes live here; the detail page reuses it via autoOpenBlockId.
  • src/pages/inventory/AirlineBlockDetailPage.tsx — thin 7-line wrapper that auto-opens a specific block by URL param (/inventory/quota/:id).
  • src/pages/inventory/FITInventory.tsx + FITDetailPage.tsx — FIT inventory (same sibling pattern).
  • src/pages/inventory/B2BFlights.tsx — B2B offer listings fed by airline blocks.
  • src/pages/inventory/GroundTransfers.tsx — ground-transfer inventory.

Routes (from src/App.tsx):

Path Component
/inventory/quota AirlineBlocks
/inventory/quota/:id AirlineBlockDetailPage
/inventory/fit FITInventory
/inventory/fit/:id FITDetailPage
/inventory/b2b-flights B2BFlights
/inventory/ground-transfers GroundTransfers

Bundle size

AirlineBlocks is the heaviest inventory chunk at roughly 38.5 KB gzipped (26 Sep 2026; budget 40 KB per scripts/check-bundle-budgets.mjs). The file is ~4,900 lines because both the list view and the full create/edit/view/sell-seats dialog tree live in one component; the drafts list and the submit review are in src/components/inventory/AirlineBlockDrafts.tsx.

Multi-leg route handling (chainLegs)

A single block can have 1..N outbound legs and 0..N return legs. UI code chains them into a display route by walking the legs array. The helper lives in src/pages/groups/Groups.tsx (line 3579) and is the same logic used in group summaries:

const chainLegs = (legs: any[] | undefined, fallbackFrom?: string, fallbackTo?: string): string => {
  if (Array.isArray(legs) && legs.length > 0) {
    const airports: string[] = [];
    const first = legs[0];
    if (first?.origin) airports.push(first.origin);
    for (const leg of legs) {
      if (leg?.destination) airports.push(leg.destination);
    }
    return airports.filter(Boolean).join(' → ');
  }
  return fallbackFrom && fallbackTo ? `${fallbackFrom} → ${fallbackTo}` : '';
};

Semantics:

  1. Start with the first leg's origin, then append every leg's destination in order.
  2. Result is a A → B → C → D chain (e.g. BLR → DOH → JED for a BLR→JED with a DOH stopover).
  3. If legs is empty/missing, fall back to the block's flat origin/destination fields.
  4. Trip type return also stores a separate returnLegs array; the UI renders outbound and return chains separated by ·.
  5. Trip type multi_city simply uses legs with 3+ entries.

Legs are JSON, not a separate table

legs and returnLegs are stored as JSONB columns on AirlineQuotaBlock — there is no AirlineBlockLeg table. Validation and normalization happens in the service layer (see createAirlineBlock in src/services/airlineBlockService.ts).

PNR handling

PNR is stored at two levels and is treated as a required, uppercase, 8-character-ish identifier:

  • Block-level PNR — AirlineQuotaBlock.pnr. Set on the create form (src/pages/inventory/AirlineBlocks.tsx:2194). The input handler uppercases on change:
    onChange={(e) => setBlockForm((p) => ({ ...p, pnr: e.target.value.toUpperCase() }))}
    
    When you save a block, its PNR is copied down into every leg object so each leg carries the same PNR by default (AirlineBlocks.tsx:1110):
    legs: outboundLegs.map((l) => ({ ...l, pnr: blockPnr })),
    returnLegs: returnLegs.map((l) => ({ ...l, pnr: blockPnr })),
    
  • Per-leg PNR — each leg object in legs / returnLegs can override the block PNR. The GroupFlight snapshot code (getGroupFlightSnapshot in src/lib/api.ts) resolves PNR with precedence leg.pnr || block.pnr || groupFlight.pnr.
  • Per-passenger PNR — BookingPassenger.pnr can override for a specific passenger (added in 20260411000000_group_flights_multi.sql).
  • Return-leg PNR — when the return journey has its own PNR, AirlineQuotaBlock.returnPnr carries it.

maxLength 8 convention

Relationship to bookings and groups

A block reaches a booking through the GroupFlight join table (introduced in supabase/migrations/20260411000000_group_flights_multi.sql):

Booking ──(groupId)──▶ TravelGroup ──(GroupFlight)──▶ AirlineQuotaBlock
                              │                              │
                              │                              └─▶ B2BFlightOffer (partner resale)
                              └─▶ BookingPassenger.flightId ──┘ (per-pax flight pick)
  • The old direct TravelGroup.quotaBlockId FK was dropped in favour of GroupFlight, so a group can now hold any number of blocks and FIT entries simultaneously.
  • Each BookingPassenger may carry a flightId (pointing at a specific GroupFlight) and an override pnr, so a single booking can split passengers across different blocks.
  • BookingPassengerFlight (added in 20260418010000_passenger_flight_junction.sql) is the per-passenger junction mirroring BookingPassengerHotel.
  • Seat accounting: the counters are derived by the database (INV-013) from the seats passengers hold (BookingPassengerFlight) and the seats sold to third parties (live B2BFlightOffer rows, INV-014); nothing on the page types them.

Permissions

Route guard: inventory.view for all /inventory/** paths (src/App.tsx:150–157).

Action Permission Enforcement
View any /inventory/** page inventory.view <ProtectedRoute>
Create block / FIT / ground / B2B offer inventory.create <PermissionGate> + server requirePermission('inventory.create') on POST routes
Save a draft block (AIR §36) inventory.create (new); inventory.create or inventory.edit (correct, discard, list) DB create_quota_block_draft, update_quota_block_draft, discard_quota_block_draft; RLS on AirlineQuotaBlockDraft
Submit a draft block — creates it and posts its purchase and deposit inventory.create (the deposit also inventory.block_payments.record) <PermissionGate> + DB submit_quota_block
Edit block / leg / seat count / sell seats inventory.edit <PermissionGate> + server requirePermission('inventory.edit') on PATCH routes
Delete block inventory.delete Seeded but not yet wired in UI — see docs/PERMISSIONS.md §8 drift item 6
Allocate block to group inventory.allocate Seeded; UI enforcement is a follow-up (drift item 6)
Fetch flight details on a leg (Airline blocks and FIT) inventory.view Edge function flight-lookup checks it; see below

Fetch flight details fills a leg's route, times and terminals from the flight number. The schedule comes from AirLabs through the edge function flight-lookup, which holds the key; the browser has none (PLT-015). When AirLabs does not know the flight, or the function is not deployed or its key is not set, the form fills from a flight with the same number already in inventory, or leaves the fields for you to type.

A payment is paid only once finance approves it (FIN-044, amended 26 Sep 2026). The block's badge reads Awaiting finance approval while a payment or the deposit waits, Paid only when approved payments cover the cost, and otherwise Partial or Unpaid. The block card and block page show the approved amount, the amount awaiting approval and what is still owed; each posted payment is marked Awaiting approval or Rejected — not counted. Post Payment still refuses more than the cost less everything recorded, pending payments included, so a payment waiting for approval cannot be paid twice. The voucher and its approval name the block's PNR.

Supplier payments recorded on a block need finance.create (the browser-built voucher) or inventory.block_payments.record — the ticketing manager's and executive's right (FIN-044), which sends the payment through record_airline_block_payment in the database: the same voucher, posted pending finance, with the overpayment guard, the contract-rate conversion and the TDS rule applied there. Post Payment shows for either. A recorder without finance.view gets the paid-from list from GET /inventory/paid-from-accounts.

Record refund from the airline (web, since October 2026; the native app has had it since September). On the block's payment section, next to Post Payment: money the airline paid back to us. The dialog asks for the amount received (in the block's currency), the bank or cash account it came into, the method (bank transfer, UPI, cheque, cash, card), a reference (UTR or the airline's credit note), the date and an optional note, then shows the two lines — Dr the account / Cr the airline's supplier ledger — and records only on Yes, record it. It calls POST /inventory/quota-blocks/:id/airline-refund, which calls record_airline_block_refund in the database — the same function and the same checks as the phone: the voucher is pending until a second person in finance approves it (FIN-032), a refund above what was paid so far, a future date or an archived block is refused, and no TDS is taken on money coming back (FIN-044, amended 25 Sep 2026). A fully cancelled block still takes its refund. The same refund sent twice within ten minutes is recorded once. The refund shows in the posted list as − amount back, marked Awaiting approval until finance approves it. The button needs inventory.block_payments.record (ticketing manager and executive, finance manager, accountant).

Record refund from the airline on a FIT (web, since October 2026). Inventory → FIT seats → open a FIT → Payment. In the payment dialog, next to Post Payment, the button shows when money has been paid to the airline for that FIT and can come back: something is paid so far (payments and the deposit, pending ones included, less refunds — what airline_block_payment_summary returns), the FIT has a ticketing supplier and it is not archived. These are the conditions the phone app uses. The dialog is the block's dialog: the amount received in the FIT's currency, the bank or cash account it came into, the method, a reference (UTR or the airline's credit note), the date (DD/MM/YYYY, not in the future) and a note, then Yes, record it / No, go back. It calls POST /inventory/fit/:id/airline-refund, which calls record_airline_block_refund with p_kind: 'fit'. The function and checks are the same as for a block: the voucher is pending until a second person in finance approves it, the amount is capped at what was paid so far, and a double click records once. A refusal shows in the database's words inside the dialog. The refund then shows in the dialog's supplier ledger activity as − amount back. The button needs inventory.block_payments.record (FIN-044). It is not shown on the FIT card or list. It does not settle a filed FIT airline cancellation: that refund is still settled by finance in Pending Refunds.

This is different from Pending Refunds in Finance → Reports: there, finance settles a refund expected from a cancellation filed against the block (Approvals). Record refund from the airline is for money that arrives back outside a filed cancellation, for example an overpayment the airline returns.

Cancel Sale (the B2B cancellation filing, POST /finance/b2b-cancellations) likewise needs finance.create or inventory.b2b_cancellations.file — the ticketing roles file through file_b2b_cancellation in the database, and finance decides (INV-014, finance.cancellations.approve_b2b). See docs/PERMISSIONS.md §6.12 for the authoritative rows.

  • AirlineQuotaBlock — core table. Created in 20260113130121_remote_schema.sql. Trip-type + JSON legs added in 20260330120000_quota_block_legs.sql. Schedule fields in 20260118030000_add_quota_schedule.sql. Block codes (human-readable) in 20260401210000_airline_block_code.sql → 20260411080000_airline_block_code_v3.sql. Initial payment in 20260330130000_quota_block_initial_payment_amount.sql.
  • GroupFlight — group × block/FIT join; 20260411000000_group_flights_multi.sql.
  • B2BFlightOffer — partner resale of block seats; 20260119124500_b2b_flights.sql + 20260411040000_b2b_offer_cancellations.sql + 20260411050000_b2b_offer_seats_total_zero.sql.
  • FITInventory — individual-traveller flight records; 20260410130000_fit_inventory.sql.
  • Airline — airline master; seeded in 20260116100000_seed_airlines.sql and expanded in 20260118000000_seed_airlines_expanded.sql.
  • BookingPassengerFlight — per-passenger flight junction; 20260418010000_passenger_flight_junction.sql.

In the native app

The staff stack of the phone app (Native app → Operations → Airline blocks) reads the same tables and calls the same database functions; the screen hides what the person may not do and the database refuses the rest.

What can be done on the phone

  • See the blocks — cards with the airline and flight, the route chained from the legs, the departure, seats sold of total with a fill bar, the PNR, and the deadline that needs attention next (AIR §21); Upcoming / All / Archived (AIR §26); search by flight number, PNR, route, airline or code. FIT seats are listed the same way, read-only. inventory.view.
  • Open a block — the legs, the seat position from the counters the database keeps (INV-013) with holds and requested releases (inventory_resource_position, INV-011), the contract and what the airline is paid after complimentary seats (AIR §15), every deadline on the row, the departures using it (GroupFlight), its seat releases and the airline's penalty bands (both read-only), the notes.
  • Create a block — inventory.create. The desktop's form: trip type, airline, supplier, PNR, outbound and return legs (add or remove a leg), seats and complimentary seats, cost per seat, currency and contract rate, input GST, the payment due date and the free-release deadline, notes. It starts as a draft (AIR §36): Save draft keeps it and posts nothing; Review and submit runs the desktop's checks (AIR §3 trip types, leg chronology, unique PNR, complimentary seats not above seats, a rate on a foreign contract — FIN-034), shows the voucher that will be posted, and Yes, submit block calls submit_quota_block, which writes the row and its purchase voucher in one transaction (FIN-030): the voucher is derived from the block by the database — Dr 1310 Stock-in-Hand for the paid seats at the contract rate, Dr GST Input Credit when the block carries input GST, Cr the supplier's SUP- ledger, the purchase_invoice ledger mirror and the supplier transaction in the contract currency. A refused submit leaves the draft with the reason. It lands pending a second person in finance (FIN-032).
  • Drafts — a tab on the list (inventory.create or inventory.edit). A draft opens to what the submit would post and what is still missing, with Edit, Submit block and Discard draft (a reason).
  • Edit a block — inventory.edit: the flight and legs, PNR, supplier, payment due and free-release deadlines, notes. A direct table update under row security, as the desktop's PATCH; the unique-PNR and chronology checks run first.
  • Archive and restore — inventory.edit; archive_airline_block (a reason; refused while seats are allocated) and restore_airline_block (AIR §26).
  • Payments to the airline — inventory.view: paid (approved), awaiting finance approval, still owed (paid seats × fare less the deposit and the payments net of refunds, in the contract currency — the desktop's guard arithmetic, INV-025) and every payment with its voucher status (airline_block_payment_summary). Record payment — inventory.block_payments.record (the ticketing manager and executive, finance): amount with the outstanding prefilled, paid from (the desktop's list of bank and cash ledgers, airline_block_paid_from_accounts), method, reference, date, note → Yes/No showing the two lines → record_airline_block_payment, which writes the desktop's voucher (Dr the supplier's SUP- ledger / Cr the account, the supplier_payment mirror, the supplier transaction) pending a second person in finance (FIN-044, FIN-032). A riyal block converts at its contract rate (FIN-034); more than is owed, a future date, an archived or fully cancelled block are refused; a supplier set up for TDS is refused unless the caller may deduct (FIN-021). "Recorded — pending finance approval." Record refund — the same permission: money back from the airline (amount up to what was paid so far, the ledger it came into, method, reference, date, note) → Yes/No → record_airline_block_refund: Dr the account / Cr the supplier's SUP- ledger, the supplier_refund mirror and the supplier's refund row, pending finance. A fully cancelled block takes no payment but takes its refund. No tax on money coming back.
  • The deposit at purchase — on New block, for a person who also holds inventory.block_payments.record: amount, the bank or cash ledger it left, reference. The submit records it through record_airline_block_payment (initial_payment — the desktop's deposit voucher) in the block's own transaction and keeps the amount on the row; a deposit with no account is refused and the draft stays a draft.
  • FIT seats — tap one for its own screen: flight, seats, contract, deadlines and notes, read-only, with the same Payments to the airline card (payments and refunds, p_kind: 'fit'). Buying, editing and archiving a FIT stay on the desktop.
  • Third-party sales — inventory.view: the seats sold on to other agencies with the buyer, price, status, invoice, the names given and the latest cancellation — filed, approved or rejected (block_third_party_sales). Sell seats to another agency — inventory.edit: buyer from the party ledgers (inv_party_accounts: a partner's receivable, a debtor, a creditor) or typed, seats up to the open count, margin per seat (GST-inclusive, as on the desktop), currency, GST on or off, a reason below cost, notes → Yes/No showing what the buyer pays, the commission and the GST → sell_block_seats_to_third_party: the offer, the revenue and COGS vouchers and the buyer's invoice in one transaction (INV-014). Tap a sale for Names — one per seat, replaced in one call (b2b_set_manifest, INV-015), and for Cancel this sale — inventory.b2b_cancellations.file or finance.create: seats (all by default), the refund to the buyer (the sale value less the charge by default), the buyer's charge, a reason, and optionally the airline's side (charge, refund, approval reference, notes) → Yes/No showing the split and where the seats go → file_b2b_cancellation: the seats move and one pending row is written, in one transaction. "Filed — finance decides." A second person in finance approves or rejects it on the desktop (decide_b2b_cancellation); the filer never decides their own (FIN-032); a rejection puts the seats back on the sale. A filing already pending on the sale is refused. The list shows the state on each sale.

What is not built in the app — each says so on screen and offers Open on the desktop:

  • Re-costing a block (seats, complimentary seats, fare, currency, rate, GST). The desktop posts the adjustment voucher that goes with a re-cost; the app does not change those fields.
  • Deciding a cancellation. Finance, on the desktop (finance.cancellations.approve_b2b).
  • An initial-payment adjustment or reversal and the other finance events (airline cancellation filings, full cancellation). Finance, on the desktop.
  • Penalty quotes, holds, unsold-seat write-offs and the manifest. Read-only where shown; done on the desktop. Seat releases on a block are in the app — see Native app → Inventory — airline blocks.
  • Buying, editing or archiving a FIT. On the desktop; the app reads it and records its payments and refunds.
  • Airlines and the six deadline columns beyond the free-release date — maintained on the desktop.

Who the buyer is. The sell dialog lists party ledgers (sundry debtors, sundry creditors, partner receivables) from GET /inventory/party-accounts, which needs only inventory.edit; before 25 Sep 2026 it read the whole chart of accounts and a ticket manager without finance.view saw an empty list. A buyer with no ledger yet is typed as a sundry buyer, and finance opens the ledger.

How the screen loads

The airline blocks list, and a block's own page with its manifest, load with one request (PRF-010): airline_blocks_screen, read as you. It brings the blocks, airlines, suppliers, offers, departures, every booking on a linked departure (for seat reconciliation), each block's payments, the payment accounts, the currencies and your drafts. Before, the list took 62 requests in 22 round trips and a block 72 in 30, growing with every supplier: the supplier list checked each supplier's creditor ledger on every load. That check is not made here; the ledger is created when a supplier is saved and when a posting needs it. Without finance.view the chart of accounts is left out and the accounts a payment may leave from come instead (FIN-044); without inventory.create or inventory.edit the drafts are left out. The screen opened again shows at once and refreshes behind. Routes: Operations screens API.