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.