Skip to content

Holds, deadlines and seat releases

Three screens that share one idea: stock that is spoken for is not stock that is available.

  • /inventory/holds — seats, rooms and vehicle places held for a named party, with an expiry.
  • /inventory/deadlines — the six airline-block deadlines and what is at risk on each.
  • /inventory/releases — giving seats back to the airline, with approval.

All three are gated on inventory.view; the actions inside them need their own permissions.

Rules: INV-011, INV-012, INV-013, AIR §14, §16, §18, §20, §21, §22, ACC-030.


1. What "available" means

Three layers, each derived, none typed:

total − allocated − cancelled/lost        = availableSeats   (the counters)
availableSeats − held − pending release   = sellable         (what you may promise)

The counters are derived from the allocation records themselves, under a row lock, by one recompute_* function per inventory type. A value typed by a browser session is replaced by the real one. An allocation past the total is refused, naming the inventory, what was attempted and what is free — where the old triggers clamped it to zero with GREATEST(0, …) and absorbed the over-allocation silently (INV-012).

Inventory Invariant
Airline block totalSeats = allocatedSeats + availableSeats + cancelledFocSeats + cancelledChargedSeats + lostSeats
FIT the same
Hotel totalRooms = roomsAllocated + availableRooms, bedsTotal = bedsAllocated + bedsAvailable
Food totalQuantity = allocatedQuantity + quantity
Ground capacity = allocatedCapacity + availableCapacity

Two corrections worth knowing: a block's allocatedSeats now counts group passenger seats and seats sold to partners (it used to count only the B2B half), and FIT had no stored counters at all — availability was recomputed in the browser on every read.

Released is a sub-count, not a fourth bucket

A seat released by a cancellation returns to sellable stock and may be re-used until it is filed with the airline (CXL-021, CXL-022). Only filed seats leave the block, into cancelledFoc / cancelledCharged / lost. The counter functions return the released-but-unfiled count separately so a dashboard can show it without double counting.

2. Holds

A hold is a claim on stock, not an allocation — it never touches allocatedSeats. It is subtracted on the way out, so it composes with the counters above rather than competing with them. It covers airline blocks, FIT, hotel rooms and ground capacity.

Action Function Permission
Hold create_inventory_hold inventory.holds.manage
Extend extend_inventory_hold inventory.holds.manage
Convert to a booking convert_inventory_hold inventory.holds.manage
Release release_inventory_hold inventory.holds.manage
Sweep expired holds expire_inventory_holds inventory.holds.manage or inventory.edit

A hold records who holds it, who it is held for, why, and when it expires — a hold with nobody to hold it for is refused by a CHECK. Creating one takes a row lock on the resource, then refuses more than is free: "Only N seats free on X — M requested (INV-012)". Converting a hold drops it in the same step the booking takes the seats, so nothing is counted twice.

An expired hold frees its seats immediately. Availability ignores any hold past its expiry; the sweep only writes the state change and the audit row. No scheduler is needed for correctness.

Defaults live in FinanceConfig, so they can be changed without a deploy: defaultHoldHours (48) and maxHoldExtensions (2). Both are working defaults awaiting a decision (AIR §35).

3. The six deadlines

Six contractually different dates on an airline block, where one column (expiryDate) used to stand in for five:

Column deadlineType Responsible Escalates to
depositDueDate deposit Finance Manager CEO
nameSubmissionDeadline name_submission Ticketing Manager GM
ticketingDeadline ticketing Ticketing Manager GM
freeReleaseDeadline free_release Ticketing Manager GM
paidReleaseDeadline paid_release Ticketing Manager GM
finalPaxListDeadline final_pax_list Operations Manager GM

expiryDate survives as a legacy alias: a trigger keeps it equal to freeReleaseDeadline in both directions so the existing block form keeps working. It is not a seventh date.

FIT has only three of the six — deposit, name submission and ticketing — because there is no block release. Passport/document and check-in deadlines are not block columns and are not covered here.

Alerts also carry a seventh type, hold_expiry, which is an alert about a hold and not a block column.

Seats at risk depends on the deadline: a free or paid release deadline puts the unsold seats at risk; every other deadline puts the sold seats at risk.

The alert ladder

T-7 → T-3 → T-1 → T-0 → overdue. Each rung fires once per deadline occurrence — a unique index makes re-running the sweep a no-op — and carries the seats at risk, the money at risk (the airline's own penalty from its contract bands, not a guess), the responsible role and the escalation authority. An urgent rung nobody acknowledges within a day escalates.

These are internal reminders only, which is the one exception INT-003 allows. Nothing reaches a customer or an airline without a person.

Nothing here runs on a schedule

pg_cron is installed by 20260929110100 and runs only the communications dispatcher (Communications) and the leave accruals (LV-062); no inventory job and no scheduled edge function exists. So the radar derives on read — /inventory/deadlines and the inv.deadline-radar widget compute every rung from the records each time they load, so nothing is missed. The durable alert rows are written when someone runs the ladder from that page (inventory.edit). Both migrations carry the exact cron.schedule commands for whichever scheduler is chosen.

4. Seat release

Releasing seats back to the airline never deletes inventory. It is a request, an approval, then a filing.

Step Function Permission
Preview preview_seat_release inventory.view or tickets.view
Request request_seat_release inventory.release.request
Approve / reject approve_seat_release / reject_seat_release inventory.release.approve, or inventory.release.approve_large above the limit; your own request only with inventory.release.approve_own (marked selfDecided)
Withdraw cancel_seat_release inventory.release.request (or an approver)
File with the airline complete_seat_release inventory.release.file or finance.create

The same steps are in the phone app — Operations → Seat releases (Native app → Inventory — airline blocks).

The preview is computed by the database at request time and stored whole on the release row, so the numbers the requester saw are the numbers the approver sees: block total, sold, available, proposed release, remaining, the deadline, the airline's release policy, the release charge, the FOC impact and the financial impact (AIR §16).

Seats stop being sellable from the moment a release is requested, so they cannot be sold out from under an approver.

Two tiers. At or below FinanceConfig.seatReleaseApprovalSeatLimit (10 by default) a Ticketing or Operations Manager signs. Above it the approver needs inventory.release.approve_large, which CEO and GM hold — ACC-030. Whether the limit should also consider penalty value is an open question: a 9-seat release inside 14 days can cost more than a 25-seat release three months out.

Maker ≠ checker, twice. The function refuses the requester, and so does a CHECK on the table itself.

The penalty is the airline's, or it is unknown. lookup_release_penalty() matches a band most-specific-first — block, then airline + route + season, then airline + route, then airline + season, then airline. With no band on file it returns "not found", never zero, and the screen says so. What the airline actually charged is recorded on the release, and the variance against the expected refund is shown.

Filing posts the ledger consequence through file_airline_cancellation, so there is exactly one airline posting path (CXL-020). A genuinely free release of seats that were never paid for posts nothing, and says so.

Filing: the refund follows the penalty, and no penalty is FOC (AIR §37). The filing form asks for the airline reference, the penalty charged and the refund. The fare for the released seats is what the request priced them at (expected refund + penalty). When the penalty is typed, the refund is filled in as fare less penalty, never below zero. The refund can still be changed when the airline refunds a different amount, and the difference is the variance above. A penalty of zero files the release as FOC: the cancellation is marked FOC, the block counts the seats as FOC-cancelled, and the release shows FOC. Any penalty files it as charged, and a free release becomes paid. The filing's note, and the Airline refund item on Approvals, name the release number, PNR, block code, route and date, seat count, FOC or the penalty, the refund, the airline reference and who filed it.

5. FOC — two different meanings

Term Meaning
focSeats complimentary seats the airline gives (AIR §15)
cancelledFocSeats seats the airline took back at no fee

focSeats is entered when the block is bought — "20 seats, 2 F.O.C" — on POST /inventory/quota-blocks and POST /inventory/fit, and is correctable on the PATCH. The payable is paid seats × fare (+ infants), and a purchase posting that bills a complimentary seat is refused by the database. The cost per passenger follows AirlineQuotaBlock.focTreatment — spread (block cost divided over every sellable seat) or margin (each paid seat carries its own fare) — and the block detail screen shows it as focPosition. A complimentary seat left over when the aircraft goes cannot be written off as an unsold cost, because it cost nothing. The release preview warns when a release could take the block below the airline's FOC threshold, but cannot price it — the contracts differ (AIR §35).

focSeats is not part of the derived counter identity (AIR §32 rule 5 is not built).

6. Drift

inventory_drift_report() lists every counter that disagrees with the allocation records — block and FIT seats, hotel rooms and beds, meal-days, vehicle capacity — with the stored value, the derived value, the difference and a severity (over_allocated, oversold_risk, drift, advisory). It is read-only and needs only inventory.view.

Applying a correction is a separate call, inventory_repair_drift(), which needs inventory.counters.repair, refuses to run without a written reason, and writes one row per counter changed recording what it said before, what it was changed to, who and why. Genuinely over-allocated rows come back under failed rather than being "fixed" — no counter edit can conjure a seat. Advisory rows are reported and never repaired automatically, because only an operator knows which number is the contract (AUD-010).

7. Blocks are archived, not deleted

A BEFORE DELETE trigger refuses to destroy a block or FIT record that still has group flight links, passenger seats, issued tickets, released-but-unfiled seats, airline cancellations, B2B offers, vouchers, ledger entries, supplier transactions or recorded human edits — and the message names what is linked. archive_airline_block() and archive_fit_inventory() (with restore twins) are the intended end of life: the row and everything hanging off it stay, and the inventory disappears from pickers and refuses new allocations. A block keyed in by mistake, never used and never edited, is still deletable. (AIR §26, AUD-002)

8. Not built

The phone app has the deadline radar (with Acknowledge and Run the ladder) and holds (list, Hold seats, Extend, Convert, Release) under Operations since #559 step 2 (Native app → Operations, deadlines and holds). The expiry sweep and converting a hold that names no booking stay on the website.

  • An authorised override to deliberately oversell with a recorded approval (INV-012). An over-allocation is simply refused.
  • Hotel rooming and visa deadlines are not in the radar (INT-120).
  • Booking pace of similar past departures, so the utilisation advice cannot say "this block will sell out — request ten more" (INT-121).
  • Per-passenger ground transport checks — capacity is counted at group level, so the rule that a passenger may only be put on transport belonging to their own group is not enforced (INV-041).
  • The airline group request → quotation → negotiation → approval → PNR front half of AIR §1–§9. Blocks still start life already purchased. Wave 5.

9. Where to look

Concern Path
Counter integrity supabase/migrations/20260920100000_inventory_counter_integrity.sql
Archive not delete supabase/migrations/20260920100100_inventory_archive_not_delete.sql
Drift supabase/migrations/20260920100200_inventory_drift_check.sql
Holds, deadlines, releases (schema) supabase/migrations/20260920110000_inventory_holds_deadlines.sql
Holds, deadlines, releases (functions) supabase/migrations/20260920110100_inventory_hold_release_functions.sql
Permissions supabase/migrations/20260920110200, 20260920115900
Dashboards supabase/migrations/20260920110300_inventory_deadline_dashboards.sql
Screens src/pages/inventory/InventoryHolds.tsx, SeatReleases.tsx, InventoryDeadlines.tsx; the phone's apps/mobile/src/app/inventory/holds.tsx, deadlines.tsx
Service layer src/services/inventoryHoldsService.ts
Tests supabase/tests/inventory_integrity.sql, inventory_holds_deadlines.sql

How the screens load

The holds and the seat releases screens each load with one request (PRF-010): inventory_holds_screen brings the holds with every picker of the new-hold form (was 20 requests, 5 one after another); seat_releases_screen brings the queue with the request form's blocks (was 8, 4). Both need inventory.view, read as you. Pricing a release in the request form still asks the database when you choose the block and seats. A tab opened again shows at once and refreshes behind. Routes: Operations screens API.