Skip to content

04 · Inventory and groups

Airline block rules live in 05-airline-group-ticketing.md. This file covers groups, hotels, meals and ground transport, and rules shared by all inventory.

Groups

INV-001 · Group capacity is calculated, not typed

Status: PROPOSED · Owner: Operations A group's booked count is the number of ACTIVE non-infant passengers (PAX-010) on non-deleted bookings. It is calculated from passenger rows; capacity is reserved atomically so two people cannot overbook the last seat. Enforced by: 20260918120000_booking_lifecycle.sql: TravelGroup.bookedCount is recounted by trigger from ACTIVE non-infant passengers on non-deleted bookings; a value typed by a browser session is replaced by the real count. Passengers with unknown DOB count as seats.

INV-002 · Capacity is checked on every path

Status: PROPOSED · Owner: Operations Creating a booking, adding passengers, converting a lead or quotation, transferring a passenger and cloning a group all check capacity the same way. Lowering a group's capacity below its booked count is refused. Enforced by: 20260918120000_booking_lifecycle.sql: triggers on BookingPassenger (insert, un-cancel, move) and Booking (group change, restore) lock the group and refuse overbooking; create_booking and move_booking_to_group check before writing; capacity cannot be lowered below the booked count. Test: supabase/tests/booking_lifecycle.sql. Since 20261007100000_booking_db_guards.sql: a traveller whose category leaves infant is checked like one added; a departure-date or age-limit change that would overbook a departure is refused up front ("Moving … to … would overbook it"); resubmit_booking checks after the status change, so a rejected booking going back counts; finance_decide_booking re-checks before approving. Test: supabase/tests/booking_db_guards.sql.

INV-003 · Group dates drive service dates

Status: DECIDED 2026-09-29 (owner) · Owner: Operations Changing a group's departure or return date flags every hotel, meal, transport and flight assignment that falls outside the new dates for operations to fix. Nothing is silently left out of window. A return date before the departure date is refused. Enforced by: 20261003120000_inventory_follows_the_calendar.sql. The TravelGroup_dates_guard trigger refuses a return before the departure. The TravelGroup_dates_moved trigger puts one job in the operations pool (Group preparation, rule INV-003) naming every service now outside the dates. inventory_outside_trip_dates() (GET /inventory/outside-trip-dates) lists every such case, including ones already in the data before the rule was enforced. Moving a departure is not refused: the move may be right, and the services are what need fixing. Test: supabase/tests/inventory_follows_the_calendar.sql. A clone (POST /groups/:id/clone) shifts every date in the planned itinerary JSON — hotel stops, transfers, flight legs — by the same days as the departure (shiftItineraryDates(), src/lib/groupItinerary.ts), and copies the programme with its dates shifted the same way (trv_clone_programme(), INV-008; cancelled activities stay behind, every copy starts planned). Test: src/lib/api.groupCloneTemplates.test.ts.

INV-004 · Group code

Status: OPEN · Owner: Management Current format: <TYPE>-<DDMON>-<LETTER>-<N>D (e.g. UMR-10APR-A-15D). The airline spec proposes <AIRLINE>-<DEP>-<RET>-<N>D-<SERIAL> for airline groups. Decide whether airline group IDs are a separate code or replace the group code. Existing codes are never regenerated. This is the system's code; the operator's own code is INV-005 and is a separate field, not a replacement.

INV-005 · The operator's own group code

Status: PROPOSED · Owner: Operations · Source: GROUP RECORD 2026-27 and BLOCK RECORD 2026-27 operations workbooks Every departure carries the code the business itself uses — 29Aug-19D-6E-A, 12Aug-18D-6E-A — the one on every workbook sheet, receipt, WhatsApp message and phone call. It is a field on the departure, not a note in metadata: searchable, shown wherever the departure is named, and printed on the roster, the manifest and the group report. It is unique per season (the company's accounting year, 1 April – 31 March), which is how the workbooks use it; the same code may come round again in a later season. A departure need not have one, and the generated groupCode (INV-004) is shown when it does not. Unlike the generated code, the operator's code stays editable — a sheet gets renamed, a departure gains a -B sibling. Enforced by: 20260924101000_operator_group_code.sql — TravelGroup.operatorGroupCode, group_season() (IMMUTABLE, so it carries the index), a normalising BEFORE trigger (trimmed, blank → NULL), the partial unique index TravelGroup_operatorGroupCode_season_key on (group_season(departureDate), upper(operatorGroupCode)), and group_display_code() / group_search_text(). Backfilled from the metadata keys the loader used. Tests: supabase/tests/operator_group_code.sql, src/lib/api.operatorGroupCode.test.ts. The staff app's Groups list finds a departure by either code, its name, or its departure date typed DD/MM/YYYY (apps/mobile/src/lib/staffGroups.ts).

INV-006 · The office stops and reopens sales on a departure

Status: PROPOSED 2026-09-27 · Owner: Operations A departure's status is worked out from its dates and the seats booked (planning, open, full, departed, completed). The office may set it by hand, always with a reason, and put it back to automatic: Stop selling sets it to Full, Reopen sales returns it to automatic, Mark departed and Mark completed close it. Who set it, when and why are kept on the departure and in the audit log.

What a hand-set status does today:

Set to Partner portal and app Traveller app Website A booking reaching the database
Full (Stop selling) not offered shown with no seats left still shown refused — no traveller can join
Departed / Completed / Cancelled not offered not offered not shown refused — no traveller can join

Enforced in the database (owner, 27 Sep 2026: Stop selling must stop selling). No traveller can join a departure set by hand to Full, Departed, Completed or Cancelled — a new booking or traveller, a partner booking, a transfer or a booking moved in, or a cancelled traveller restored — with the message "Sales for are stopped (): no traveller can be added. Reopen sales on the group's Overview first." Leaving, cancelling and editing are never refused; nothing already on the departure changes. The website poster still shows a Full departure (not built). Migration 20261001210000_stopped_sales_refuse_new_travellers.sql (triggers beside the capacity checks of INV-002); test supabase/tests/stopped_sales_refuse_new_travellers.sql.

Closed (the web's "delete"). Deleting a departure on the web closes it: TravelGroup."isActive" becomes false and everything else is kept. A closed departure is shown as Closed on the web, and in the phone app it is not in the staff Groups list's Active view, shows a Closed chip under All groups, says Closed in search, is not in a tour leader's My groups (fld_my_groups, 20261008220000), and is not offered for a new booking (issue #557). Enforced by: PATCH /groups/:id (groups.edit; a reason is required to set or change an override; audited as status_override_set / status_override_cleared); public_groups() / public_departures() leave out departed, completed and cancelled (TRV-008); the partner wizards offer planning and open only. On screen: the group's Overview tab (src/components/groups/GroupSalesStatusCard.tsx). Test: src/components/groups/GroupSalesStatusCard.test.tsx.

INV-007 · The group report can show who has paid and what is due

Status: DECIDED 2026-09-28 · Owner: Owner · Source: the owner, 28 Sep 2026 — "in the print group report add an option of who has paid and how much is due or total amount"

The printed group report has an Include money option, off unless it is ticked. With it on, the report shows each booking's Total, Paid and Due, and a totals row for the group. The figures are the booking's own — the ones its booking page and the group's Overview show; the report works nothing out again. On the passenger list a booking's figures are printed once, beside its first traveller, never repeated per person. A filter keeps All bookings, only those Fully paid (nothing due) or only those that Have a balance due; the travellers listed follow the filter. A booking awaiting finance approval prints Awaiting finance approval — ₹X will be due, counts as having something due, and is totalled apart from the balances (FIN-033).

A report with money says Includes payment details — internal at the top, so it is not sent to a hotel, an agent or a supplier by mistake.

Only staff who may already see booking money are offered the option: bookings.view or finance.view, the rights that show a customer's paid and due elsewhere (PERMISSIONS §6.5). The amounts come from what the group page has already read (GET /groups/:id/screen, under the database's row security); the only read the print adds is each booking's agreed due and approval state (the computed fields booking_agreed_due / booking_awaiting_approval, read as the caller under the same row security), for FIN-033. The gate is on screen only: it narrows who is offered the option and opens no new data — the same totals are already on the group's Overview.

Enforced by: the print dialog (src/components/groups/PrintGroupReportDialog.tsx, gated on bookings.view or finance.view), reportMoney() (src/lib/groupReportMoney.ts) and printGroupReport() (src/lib/printGroupReport.ts). Tests: src/lib/groupReportMoney.test.ts, src/lib/printGroupReport.test.ts, src/components/groups/PrintGroupReportDialog.test.tsx. Not built: money on the rooming list, flight manifest and meal count exports (they go to suppliers and stay without money), and a per-traveller share of a booking's total.

INV-008 · A departure has one itinerary and one programme

Status: DECIDED 2026-10-02 (owner) · Owner: Operations Staff and travellers see the same itinerary for a departure. Its flights, hotel stays and transfers are the ones linked to the group. The planned itinerary (the group's own plan, typed in the Edit Group dialog) fills in a kind only while the group has nothing of that kind linked. A hotel or transfer in inventory that the group has not taken is never part of the itinerary, even when its contract covers the trip.

A departure has one programme: the activities of each day (rituals, ziyarat, meals, meeting points, free time). It is kept in one place and edited in two: the day-by-day planner on the group's Itinerary tab (web) and the programme on the phone (TRV-003). Staff, the tour leader, the travellers, Customer 360, the website poster, the printed report and Copy all read it. Every activity has a date; on the planner it is one of the days from the departure to the return. Within a day, activities run in time order, untimed ones last, and staff set the order of the rest. A cloned departure takes the programme with it, every date moved by the same days (INV-003).

The activities once typed in the Edit Group dialog (the itinerary JSON) were copied into the programme on 6 Oct 2026, once. An activity without a date went on the departure date with "(date to confirm)" in its notes. That JSON list is no longer read or written and is kept as it was; no data was deleted. Enforced by: jrn_group_itinerary(p_group_id) for travellers, Customer 360 and the field app — since 20261006160000_one_programme_per_departure.sql its activities are the GroupActivity rows (not cancelled), never the JSON ones. The group's Itinerary tab builds the same flights, hotels and transfers in mergeGroupItinerary() (src/lib/groupItinerary.ts) from the rows GET /groups/:id/screen already returns (PRF-010), and reads the programme with GET /groups/:id/programme (trv_trip_programme) when the tab opens. The programme is written only by trv_upsert_activity, trv_reorder_activities and trv_delete_activity (groups.edit, or the group's leader with field.checkin); the order is GroupActivity."sortOrder" after the start time. programme_copy_json_activities() made the one-off copy (sourceKey unique, so it runs once); trv_clone_programme() copies a cloned departure's programme. The poster's days (public_departures) read GroupActivity. On the planner the day is picked from the trip's days; the database itself accepts any date, so an activity left outside the trip after the departure moves (INV-003) shows at the end of the planner, marked, to be moved back. Tests: supabase/tests/one_programme_per_departure.sql, src/lib/groupProgramme.test.ts, src/components/groups/DayPlanner.test.tsx, src/lib/api.groupProgramme.test.ts, src/lib/api.groupCloneTemplates.test.ts, src/lib/groupItinerary.test.ts, src/components/groups/MovementChart.test.tsx, src/pages/groups/Groups.itinerary.test.tsx. A programme can be saved as a template, and a departure started from one (INV-009). Not built: drag-and-drop between days (Move up/down and Move to another day do it).

INV-009 · A programme can be saved as a template

Status: DECIDED 2026-10-02 (owner, "planner templates") · Owner: Operations Staff with groups.edit save a departure's programme (INV-008) as a named template, for one trip type or for any. A template keeps each activity by its day of the trip — Day 1 is the departure date — with its kind, start and end time, title, place and notes, in the planner's order. Cancelled activities are not saved, nor any dated before the departure. Template names are unique, whatever the case.

A departure with no programme is started from a template. A template can also be added to a programme that already has activities; the planner asks first, and says that it adds and changes or removes nothing. The templates for the group's trip type are offered first. Each activity lands on the departure date plus its day, at the end of that day, as planned. An activity whose day falls after the return date lands on the return date, with "(outside the trip — check)" added to its notes. It is not dropped: staff see it on the last day and move or delete it. The planner lists these activities before anything is added. A departure without a return date takes every activity on its own day.

A template applied twice to the same departure adds nothing the second time. An activity deleted from the programme comes back if the template is applied again. Renaming a template does not change departures already started from it. Deleting a template leaves their activities alone.

Enforced by: 20261007130000_programme_templates.sql. Tables ProgrammeTemplate and ProgrammeTemplateItem (dayOffset 0 = Day 1) are read under RLS with groups.view and never written by a login directly. programme_template_save, programme_template_apply, programme_template_update and programme_template_delete check groups.edit themselves and write AuditLog (an apply that adds nothing is not logged); programme_templates() lists them. An applied activity carries GroupActivity."sourceKey" = tpl:<groupId>:<templateItemId> (unique), which makes a repeat a no-op. Routes: /programme-templates (API). Tests: supabase/tests/programme_templates.sql, src/lib/programmeTemplates.test.ts, src/components/groups/ProgrammeTemplates.test.tsx. Not built: editing a template's activities in place (save the programme again under a new name, then delete the old one), and starting a programme from a template on the phone (the phone edits the programme a template made).

All inventory

INV-010 · Every allocation has an owner and a state

Status: PROPOSED · Owner: Operations Every seat, room/bed, meal plan and transport seat allocated to a passenger has a state (HELD, CONFIRMED, USED, RELEASED, CANCELLED, NO_SHOW). Cancelling, transferring or deleting a passenger changes the state in the same step (LC-020).

INV-011 · Holds expire

Status: PROPOSED · Owner: Operations · Source: airline spec §14 A hold records who, for whom, why, and an expiry. On expiry the responsible person is alerted and the hold is released or extended. Enforced by: 20260920110000_inventory_holds_deadlines.sql (InventoryHold over airline blocks, FIT, hotel rooms and ground capacity — holder, party held for, reason, expiresAt, state; a CHECK refuses a hold with nobody to hold it for) + 20260920110100_inventory_hold_release_functions.sql (create_inventory_hold, extend_inventory_hold, convert_inventory_hold, release_inventory_hold, expire_inventory_holds). Held units stop counting as available through inventory_held_units(), which ignores a hold the moment it expires — the sweep writes the state change and the audit row, it is not what makes the seats sellable. Extensions are capped by FinanceConfig.maxHoldExtensions and the house default length by FinanceConfig.defaultHoldHours (working default 48 h — see the open questions below). The responsible person is alerted by name through the hold_expiry rung of the AIR §22 ladder. UI: /inventory/holds, dashboard widget inv.hold-expiry; in the phone app Operations → Holds (apps/mobile/src/app/inventory/holds.tsx: list, Hold seats, Extend (not on an expired hold — see INV-012), Convert, Release — the same functions, called directly), with the active and expiring counts on the Operations tab from app_operations_summary() (20261008235000_app_operations.sql). Tests: supabase/tests/inventory_holds_deadlines.sql, supabase/tests/app_operations.sql, apps/mobile/src/lib/operations.test.ts.

A hold is a claim on stock, not an allocation: it never touches allocatedSeats. It is subtracted on the way out, in block_available_seats() and its siblings, so it composes with the derived counters in INV-013 rather than competing with them.

INV-012 · Nothing is over-allocated

Status: PROPOSED · Owner: Operations Allocation beyond available inventory is refused, except with an authorised override that is recorded. Enforced by: 20260920100000_inventory_counter_integrity.sql. recompute_block_counters, recompute_fit_counters, recompute_hotel_counters, recompute_food_counters and recompute_ground_counters lock the inventory row, derive the counters from the allocation records and raise — naming the inventory, what was attempted and what is available — where the old triggers clamped with GREATEST(0, …) and absorbed the over-allocation silently. They run from triggers on BookingPassengerFlight, GroupFlight, B2BFlightOffer, GroupHotelAssignment, GroupFoodAssignment and GroupGroundTransferAssignment, and on any resize of a total. The row lock is what makes two sessions racing for the last seat safe: the second one waits, then counts the first one's committed row and is refused. Non-negative CHECK constraints back it up. Covers airline block seats, FIT seats, hotel rooms and beds, meal-days and vehicle capacity.

Also enforced by 20260920110100_inventory_hold_release_functions.sql one layer out: a hold or a seat release larger than what is free is refused, counting held units and releases already requested. The two layers are different questions — "may this seat be claimed" (hold/release) and "may this seat be given to a passenger" (allocation) — and both are refused rather than clamped.

An expired hold is not extended (20261008235000_app_operations.sql replaces extend_inventory_hold). Once a hold is past its end its units count as free again (inventory_held_units() ignores it), so they may have been sold since. Extending it would re-hold them without checking. The function refuses a hold whose expiresAt has passed ("This hold has expired; make a new hold so the seats are checked again") and a new expiry that is not in the future; a new hold goes through create_inventory_hold, which counts what is free. This applies to the website and the phone alike; the phone also hides Extend on an expired hold. Test: supabase/tests/app_operations.sql.

One truth for a seat (20260927100000_one_truth_for_a_seat.sql). The guard above counts BookingPassengerFlight — the row that means "this pilgrim occupies a seat on that flight". Every screen, and the block manifest, counted BookingPassenger."flightId", a column left over from before that table existed. The Groups page assigned a flight by writing the column, so no junction row was created, no trigger fired, and the counter saw none of it: block 6E-06OCT26-A-RW sold 10 of its 20 seats to a third party, then took a 12-passenger departure, and the system said yes twelve times. The screen read 22/20 and every check agreed there were 10 seats free.

Now:

  • A seat is taken by putting the passenger on the flight (POST /sales/bookings/:id/passengers/:pid/flights). BookingPassenger."flightId" is a mirror the database keeps, and a browser write to it is refused, naming the route that counts the seat.
  • Linking a block to a departure that cannot fit is refused up front, saying how many seats are left and how many the departure needs — rather than letting someone find out at traveller 11 of 12.
  • The guard tells "making it worse" from "putting it right". It refuses the allocation that goes further over, and allows one that comes down. Without that an already-oversold block could never be corrected: freeing a seat recomputes, is still negative, raises, and the repair is refused.
  • inventory_over_allocated_blocks() names every block that is over, and by how many. The stored availableSeats is clamped at nought by its CHECK, so this is the only place the overage can be seen; the drift checker could not report it because the counters and the truth agreed.

Pre-existing over-allocations are not silently truncated. The seats are already taken in the real world, so the migration adopts them, reports them and leaves the decision — which traveller loses a seat — to a person (AUD-010).

The authorised override in this rule is not built — there is no way to deliberately oversell with a recorded approval. An over-allocation is simply refused. Tests: supabase/tests/inventory_integrity.sql, supabase/tests/inventory_holds_deadlines.sql, supabase/tests/one_truth_for_a_seat.sql, src/lib/api.seatIsCounted.test.ts.

INV-013 · Inventory totals are derived

Status: PROPOSED · Owner: Operations Available = purchased − allocated − released − cancelled, calculated from the allocation records. Stored counters, if kept for speed, are verified nightly by the drift checker (AUD-010). Enforced by: 20260920100000_inventory_counter_integrity.sql defines the derivation once, in inv_block_counter_truth / inv_fit_counter_truth / inv_hotel_counter_truth / inv_food_counter_truth / inv_ground_counter_truth; nothing else may invent its own formula. Stored counters are written only by the matching recompute_* function, so a value typed by a browser session is replaced by the real one. The identities now guaranteed:

invariant allocated means
airline block totalSeats = allocatedSeats + availableSeats + cancelledFocSeats + cancelledChargedSeats + lostSeats + unsold seats written off active group passenger seats + seats transferred to partner agents (B2B) — previously allocatedSeats counted only the B2B half
FIT same identity active group passenger seats (FIT had no stored counters at all; availability was recomputed in the browser on every read)
hotel totalRooms = roomsAllocated + availableRooms and bedsTotal = bedsAllocated + bedsAvailable rooms and beds on GroupHotelAssignment, written by one owner so they can no longer disagree

A derived counter still needs a default (20260922110000). HotelInventory."availableRooms" is NOT NULL and the seeding trigger is AFTER INSERT, so when POST /hotels correctly stopped sending the derived counter the not-null check fired first and no hotel could be created at all. Its four sibling derived counters all carried DEFAULT 0; this one was missed. It has one now, and the route sends a seed value as well so it works against a database that has not run the migration. The sibling inventory routes (FIT, food, ground transfer) have no derived NOT NULL counter without a default. | food | totalQuantity = allocatedQuantity + quantity | meal-days on GroupFoodAssignment; quantity keeps its meaning (free stock) but is now derived, and totalQuantity records what was purchased | | ground | capacity = allocatedCapacity + availableCapacity | seats on GroupGroundTransferAssignment |

Sellable stock is one step further out, and is 20260920110100_inventory_hold_release_functions.sql's job: block_available_seats(), fit_available_seats(), hotel_available_rooms(), ground_available_places() and inventory_resource_position() take the derived availableSeats / availableRooms / availableCapacity above and subtract inventory_held_units() and inventory_pending_release_units() — so the full contract is sellable = total − allocated − cancelled/lost − held − pending release. The two layers stack: the counters say what is owed to passengers, the hold layer says what is spoken for.

A seat written off as unsold leaves the block for good (FIN-037, 20260923131000). It joins withdrawnSeats alongside cancelled and lost seats, so availableSeats drops and an attempt to assign it is refused naming the write-off. It is still reported, as writtenOffSeats on inv_block_counter_truth() and inventory_resource_position(), because an auditor needs to see the capacity nobody took. Being derived, the counter follows the record: reverse a wrong write-off and the seat is sellable again. Test: supabase/tests/unsold_seat_write_off.sql.

A note on "released": a seat released after a customer cancellation returns to sellable stock and may be re-used until it is filed with the airline (CXL-022, AIR §32 rule 4), so released-but-unfiled seats are a sub-count of available, not a fourth disjoint bucket in the stored identity. Only filed seats leave the block, into cancelledFoc / cancelledCharged / lost. inv_block_counter_truth() returns the released sub-count separately so dashboards can show it without double counting, and inventory_pending_release_units() holds back the ones inside a requested or approved SeatRelease so they cannot be sold from under an approver.

The nightly check is inventory_drift_report() (20260920100200_inventory_drift_check.sql) — see AUD-010. Cloning a group no longer copies rooms or beds, only the hotel link, because the clone points at the same physical contract. Tests: supabase/tests/inventory_integrity.sql, supabase/tests/inventory_holds_deadlines.sql.

INV-014 · A third-party seat is counted by its status

Status: DECIDED (2026-09-25) · Owner: Operations · Source: the 22-on-20 block (INV-012) Seats sold on to another agency (B2BFlightOffer, and a partner's resale of them, PartnerSeatSale) are seats on the flight while the sale is live and not a moment longer. A cancelled sale holds no seats: the database refuses a CANCELLED offer with seatsTotal above zero, so every sum of seatsTotal is right whether or not it remembers to look at the status. A resale counts in the block's third-party revenue only while both it and the offer it came from are live. A sale is cancelled through the B2B cancellation filing, which returns the seats and posts the money; a status typed on the offer is refused.

Enforced by: 20260928150000_a_third_party_seat_is_counted_by_its_status.sql — the check constraint B2BFlightOffer_cancelled_has_no_seats and block_pnl(); PATCH /inventory/b2b-flights/:id refuses status: CANCELLED; the Airline Blocks seat snapshot, GET /inventory/status, GET /inventory/pnl, the block list and the block manifest read live offers only. Tests: supabase/tests/a_third_party_seat_is_counted_by_its_status.sql, src/lib/api.thirdPartySeats.test.ts.

The native app sells seats on through sell_block_seats_to_third_party (20260929150000, inventory.edit): what POST /inventory/third-party-sale does in the browser — b2b_transfer_seats, the revenue voucher (Dr the buyer's ledger, a partner's AGR- receivable when the buyer is one; Cr Sales for the cost, Commission Earned for the net margin, GST Payable on it), the COGS voucher (Dr Purchase A/c / Cr Stock-in-Hand), the partner's statement line and the buyer's invoice — in one transaction, so a refused voucher leaves no seats carved (the browser queues a PostingFailure and leaves them gone). A loss needs a reason; an archived block sells nothing. block_third_party_sales lists a block's sales for the screen, each with its latest cancellation (filed, approved or rejected). Parity (2 seats at 51,010 with a 5,900 GST-inclusive margin → Dr AGR- 1,13,820 / Cr 4000 1,02,020 / Cr 4100 10,000 / Cr 2400 1,800; COGS Dr 5000 1,02,020 / Cr 1310 1,02,020) is proved in supabase/tests/ticketing_pays_for_its_blocks.sql.

The filing, from the phone (20260929200000; supabase/tests/ticketing_files_and_refunds.sql, src/lib/api.blockPayments.test.ts). Cancelling a sale is still the finance filing — but the ticketing manager or executive who sold the seats may file it: a permission, inventory.b2b_cancellations.file (SUPER_ADMIN, TICKET_MANAGER, TICKET_EXEC and every role holding finance.create), and file_b2b_cancellation(p_offer_id, p_reason, p_buyer_cancellation_charge, p_supplier_side, p_seats, p_refund_amount) — what POST /finance/b2b-cancellations does in the browser (the route's refusals in its words; the offer shrunk, or CANCELLED with no seats when the whole sale goes; the seats back on the block, or into cancelledChargedSeats when cancelled with the airline; one pending_approval row) in one transaction. Filing only: journals post on approval, the decision stays with decide_b2b_cancellation and finance.cancellations.approve_b2b, and the filer can never decide their own (FIN-032); a rejection puts the seats back on the sale. The seats default to the whole sale and the refund to the sale value less the buyer's charge; a filing already pending on the offer is refused, the same filing sent again within ten minutes returns the first. The web route sends a caller who lacks finance.create and holds the permission through the function; finance keeps its browser-built path. Parity: one seat of a 56,910 sale with a 5,000 charge → refund 51,910, reversed cost 51,010, reversed commission 762.71 + GST 137.29; the whole 3-seat sale cancelled with the airline → offer CANCELLED, seatsTotal 0, cancelledChargedSeats +3, open seats unchanged.

Before this the counter skipped cancelled offers but the screens did not: the Airline Blocks page, the inventory status board and the P&L summed every offer, the P&L counted resales under a cancelled offer, and an offer PATCHed to CANCELLED kept its seats, so a block could show fewer free seats than it had.

INV-015 · A manifest name belongs to its sale

Status: DECIDED (2026-09-25) · Owner: Operations · Source: the 22-on-20 block (INV-012) A name on a block's manifest is one of two things: a booked pilgrim (from the booking, INV-012) or a name the third-party buyer gave us for a seat they bought. There is no third kind. A buyer's name belongs to one live sale on this block, at most one name per seat, and goes when the sale goes. A sale's names are replaced in one call and one transaction (b2b_set_manifest), so a refused list leaves the old one standing rather than nothing.

Enforced by: 20260928150000_a_third_party_seat_is_counted_by_its_status.sql — foreign keys from B2BPassenger to the offer (cascade) and the resale (set null), the B2BPassenger_guard trigger, b2b_set_manifest() (needs inventory.edit), and POST /inventory/quota-blocks/:id/b2b-passengers calling it; the native app's Names sheet on a sale calls b2b_set_manifest directly, one field per seat (20260929150000). The "+ Add Manual Passenger" button is gone. Rows that already name a sale that does not exist are counted by the migration and left for a person (AUD-010); the key is validated the moment they are gone. Tests as INV-014.

Before this the manifest table had no foreign keys. The manual-passenger button saved names under a made-up sale id nothing ever read back, so the names vanished; a name could be saved against another block's sale, a cancelled sale or no sale; twelve names could sit on a ten-seat sale; and the names of one sale were listed once per resale under it.

INV-016 · A seat offer to partners is opened with inventory.create and closed with inventory.edit

Status: PROPOSED · Owner: Operations · Source: issue #559 step 5 (partner offers on the phone)

Seats of an airline block are offered to partners as a B2BFlightOffer. Partners buy them in their own app or portal at the offer's price, never seeing the block's cost or PNR (PTR-060). - Opening an offer needs inventory.create, as on the website's B2B Flights page. The seats come out of the block at once (INV-013). - Only free seats are offered. Seats held for someone, and seats waiting to go back to the airline, are not counted as free (INV-011, INV-012). The refusal says how many are free and how many were asked for. - An archived block, a block cancelled with the airline, and a block that has flown offer nothing. - Closing an offer needs inventory.edit and a reason. Partners can no longer buy from it. Its seats stay with it; they do not go back on sale by themselves. - A sold offer is not closed. Closing it would stop the partner reselling the seats they bought (PTR-041). A sale is cancelled through the filing (INV-014). An offer carved for a departure is changed from the departure. - Opening and closing are audited (b2b_offer_opened, b2b_offer_closed, with the reason). The same request twice (a double tap) opens one offer; closing a closed offer changes nothing.

Enforced by: on the phone, app_open_b2b_flight_offer (which calls the website's own b2b_transfer_seats) and app_close_b2b_flight_offer (20261009120000_app_inventory.sql), which check the permission themselves. Tests: supabase/tests/app_inventory.sql, apps/mobile/src/lib/b2bOffers.test.ts.

Not covered yet: the website still opens an offer through b2b_transfer_seats directly (which also accepts inventory.edit, and does not count held seats), and closes one by writing B2BFlightOffer from the browser after requirePermission('inventory.edit'), without storing the reason it asks for. The table's write policies only ask for a staff login (is_staff_user(), 20260401170000), whatever its permissions, so on that path the browser check is the only permission check. The same holds for B2BHotelOffer (the … staff policies of 20260918100000). Moving the website onto these functions, and narrowing the row policies, is its own change. Offering hotel beds to partners has a route (POST /b2b-hotel-offers) but no screen on the website, and none on the phone.

INV-025 · An individual seat is a seat

Status: PROPOSED · Owner: Operations An individual (FIT) air seat is answered the same way an airline block seat is, everywhere a seat purchase is asked a question: what it holds, who is using it, what it has sold, what money has happened to it, and what becomes of it when nobody flies it. Where the two genuinely differ, the difference is named — a silent zero reads the same as a checked zero.

Found on the live 12 Aug 2026 departure: 13 travellers on a block, 3 on individual tickets, the database correct and identical in shape for both, and the FIT overview reporting "Available", "Open Seats 3 / 3", "0 linked groups", "Bookings sold 0", "Customer revenue ₹0.00".

Enforced by: the API and screens, plus 20260924122000_a_fit_answers_the_same_questions.sql for the two database functions that answered the FIT case with a constant. What this covers:

  • Seat counters. GET /inventory/fit returns allocatedSeats and availableSeats — the derived columns the database maintains and refuses an over-allocation on (INV-012) — with the same live cross-check the block list does, plus writtenOffSeats (FIN-037) and focSeats / focTreatment (AIR §15). GET /inventory/fit/:id exists and returns focPosition, as the block's single read has since R11.
  • Links. A departure reaches a FIT through GroupFlight, never the legacy TravelGroup.fitId. The FIT screen, the capacity board and the inventory P&L all read it there, and seats are counted per GroupFlight so a traveller on a block in the same departure is not counted against the FIT.
  • Money. POST /inventory/fit/:id/finance-events takes initial_payment_adjustment, initial_payment_reversal and full_cancellation as well as supplier_payment and cancellation. GET /inventory/fit/:id/finance-events answers with the history (so does the block's). The supplier overpay guard covers a FIT purchase, which it exempted.
  • Unsold seats. POST /inventory/fit/:id/write-off-unsold exists and shares the block's implementation — see FIN-037.
  • Deletion. A FIT with GroupFlight rows pointing at it cannot be deleted, and a supplier with FIT rows cannot be hard-deleted.
  • Named differences. An individual ticket has no B2B partner sale, no release penalty band and no multi-leg legs / returnLegs, because B2BFlightOffer, AirlineReleasePolicyBand and FITInventory have no column for any of them. preview_seat_release() now says so in policy.basis instead of returning a bare zero.

Still not built, and not silent about it: one pnr and one pricePerSeat per FIT row, so three tickets bought separately at their own fares can only be entered as N identical seats under the first PNR; inv_fit_counter_truth() returns no releasedUnfiledSeats, so the drift checker cannot report a FIT's released-but-unfiled seats; and there is no FIT seat-release screen, no FIT manifest and no fit entity in the cancellation summary report. See docs/api/inventory.md.

Tests: src/lib/api.fitParity.test.ts, supabase/tests/unsold_seat_write_off.sql.

Transfers

INV-020 · Inventory follows a transferred passenger

Status: DECIDED 2026-09-17 · Owner: Operations See LC-030. Old group allocations are released; equivalent inventory in the new group is assigned where available; unmatched items go on the "needs assignment" list. Enforced by: Partly — src/lib/api.ts transfer. Seats are matched to the same block or FIT in the new departure where one exists, and released otherwise. The room, the meal plan and the coach seat are released, because they belong to the departure and not to the traveller; they used to follow the traveller and leave them occupying a room in the group they had left. They come back in the transfer's response as releasedServices. Picking equivalent inventory in the new departure automatically, and the standing "needs assignment" list, are still not built.

Hotels

INV-030 · Rooms by room-share group

Status: PROPOSED · Owner: Operations Rooms used = number of distinct room-share groups, not number of passengers. Every passenger with hotel service belongs to a room-share group; infants are placed with their guardian (PAX-012). One formula is used everywhere (currently the trigger and the capacity dashboard disagree). Enforced by: Partly. 20260920100000_inventory_counter_integrity.sql fixes the half of this that was drifting: HotelInventory.roomsAllocated / availableRooms / bedsAllocated / bedsAvailable are now derived together by recompute_hotel_counters() from GroupHotelAssignment, so rooms and beds cannot disagree and neither can be typed from the browser. GroupHotelAssignment.roomsUsed — rooms actually occupied, counted by distinct room-share group (recompute_hotel_rooms_used, 20260418020000) — is still a separate figure from roomsAllocated, the rooms held under the contract. Reconciling the two formulas, and excluding infants, is still open.

INV-031 · Hotel availability is by date

Status: DECIDED 2026-09-29 (owner) · Owner: Operations Availability is checked per night against the lease window, not as one total.

  • The lease's last date is the check-out day. An allotment "01–30 Nov" is 29 nights, 01 to 29 Nov. This is how the purchase voucher has always counted nights (20260929020000), and the allotment calendar now shows the same. A stay may check out on the lease's last date at the latest.
  • A stay that takes rooms has a check-in and a later check-out, and every one of its nights lies inside the allotment. The owner's report that led to this: a 01–30 Nov allotment accepted a departure staying 27 Oct – 10 Nov. It is now refused, and the refusal names the allotment and the nights outside it (27/10/2026 – 31/10/2026).
  • Rooms and beds are counted night by night. Two departures on different nights can each use the whole allotment. On a night they share, the second is refused, and the refusal names that night, the rooms given out and the rooms free. "Rooms free" on the hotel is the allotment minus its busiest night.
  • The allotment itself is guarded. Shrinking it below a night in use, or moving its lease so a stay falls outside, is refused, naming the night or the departure.
  • Putting things right is always allowed. Fewer rooms, or a shorter stay, is never refused, even on an allotment that was over before these checks existed.
  • A link with no rooms and no beds is a placeholder (a cloned departure is linked this way until its dates are firm, INV-013). It needs no dates, and takes nothing.

Enforced by: 20261003120000_inventory_follows_the_calendar.sql: - GroupHotelAssignment_stay_guard (inv_hotel_stay_guard) checks each stay, locking the allotment so two groups cannot race for the last rooms on a night - inv_hotel_counter_truth / recompute_hotel_counters compute the busiest night - HotelInventory_contract_guard guards changes to the allotment itself - the allotment calendar (HotelAllotmentCalendar.tsx) stops at the check-out day - the web and phone assign forms ask for both dates, and leave the room count to the database, which answers night by night

Test: supabase/tests/inventory_follows_the_calendar.sql (31 scenarios).

INV-032 · Every service stays inside its departure

Status: DECIDED 2026-09-29 (owner) · Owner: Operations A departure's services fall within its dates: - a hotel stay: check-in no earlier than the departure, check-out no later than the return - a meal plan with dates: inside the departure, and inside its meal contract's dates - a transfer, and a linked airline block or FIT: within one day of the departure's dates (overnight flights, time zones)

A service outside is refused, and the refusal names the service's dates and the departure's. A cloned departure lists any hotel or flight it could not copy for this reason, under the clone's "not copied" list. It never counts them as copied. Enforced by: 20261003120000_inventory_follows_the_calendar.sql: - hotel stays: inv_hotel_stay_guard - flights: GroupFlight_date_guard - meal plans: GroupFoodAssignment_date_guard - transfers: GroupGroundTransferAssignment_date_guard - clones: POST /groups/:id/clone reports these as skipped

Tests: supabase/tests/inventory_follows_the_calendar.sql, src/lib/api.lifecycleHotfixes.test.ts.

INV-033 · A departure's flights are linked with groups.edit, one departure per block

Status: PROPOSED · Owner: Operations · Source: issue #559 step 3 (group operations on the phone)

A departure flies on the airline blocks and FIT tickets linked to it. Linking one, setting or clearing its PNR and unlinking it need groups.edit, as on the website's Flights tab. - A block or a FIT ticket is linked to one departure at a time. Linking it to a second one is refused, naming the departure that holds it. - A block is refused when it has fewer free seats than the departure's travellers who need a seat (not cancelled, not an infant, ticket not left out) — INV-012. The refusal says how many are free and how many are needed. - The flight flies within a day of the departure's dates (INV-032). A draft block cannot be linked (AIR §36). - A flight with a traveller on it is not unlinked. Take the travellers off it first. - Every link, PNR change and unlink is audited (flight_linked, pnr_updated, flight_unlinked). - Linking the same flight twice, or unlinking one already gone, changes nothing (a double tap).

Enforced by: on the phone, app_link_group_flight, app_set_group_flight_pnr and app_unlink_group_flight (20261009100000_app_group_operations.sql), which check groups.edit themselves. One departure per block or FIT: the unique indexes of 20260411020000_group_flight_exclusive.sql. A traveller on the flight: the BookingPassengerFlight foreign key (ON DELETE RESTRICT). Tests: supabase/tests/app_group_operations.sql, apps/mobile/src/lib/groupOps.test.ts.

Not covered yet: the website still links, re-PNRs and unlinks by writing GroupFlight from the browser after requirePermission('groups.edit'), and its seat check runs in the browser. GroupFlight's row policy (authenticated_full_access, 20260411000000) lets any signed-in login write the table, so on that path the browser check is the only one. Moving the website onto these functions, and narrowing the row policy, is its own change.

Meals and transport

INV-040 · Catering is bought per pilgrim per day; being fed is not being billed

Status: DECIDED 2026-09-22 · Owner: Operations Follows PAX-010. A meal contract is priced at a rate per pilgrim per day, so the head-count is the whole cost: a departure's catering is rate × meal-days × travellers.

"Exclude children / infants" and "exclude group leader" mean those people are fed and do appear in the counts sent to the caterer — they are simply not charged for. So a meal plan carries two head-counts:

What it is What it drives
quantity travellers fed what the caterer cooks for, and — times mealDays — the meal-days drawn from the contract (INV-042)
chargeableQuantity travellers billed the Stock-in-Hand (1310) → Food Expense (5300) consumption journal, and the departure's cost report

Who is fed: every active (not cancelled) passenger on a non-deleted booking of the departure who did not opt out of meals (PAX-034). Who is billed: the same people less the children and infants when "exclude children" is set, and less everyone on the booking the group leader travels on when "exclude group leader" is set (the leader is one traveller — PAX-035) — each person spared once, so a child on the leader's booking is not deducted twice. The operator can override the fed head-count on either assign dialog; the exclusions are then taken off the number they typed, and the plan becomes fixed — it no longer follows the manifest (INV-042).

Enforced by: handleFoodAssignments POST in src/lib/api.ts derives both counts from the departure's passengers and posts the consumption journal on chargeableQuantity; computeGroupCostBreakdown() reads chargeableQuantity ?? quantity so the departure's cost matches what was posted. 20260926120000_catering_is_bought_per_pilgrim.sql adds the column and refuses a chargeable count below zero or above the fed count. GET /groups/:id/exports/meal-count is deliberately untouched — children and the leader are still on the list the caterer gets.

Both assign screens used to send quantity: 1, so a 40-pilgrim departure posted one pilgrim's catering, food stock never depleted, and the second traveller put on the plan was told "Meal assignment is fully booked: 1/1 portions used." No head-count is defaulted to 1 any more; a departure with no travellers assigns 0 and posts nothing.

Tests: src/lib/api.foodAssignmentCost.test.ts.

The per-day limit (capacityPerDay) is enforced where meal plans carry dates. A plan is refused on the first day that it and the plans overlapping it would feed more than the contract's daily limit, and the refusal names that day (GroupFoodAssignment_date_guard, 20261003120000). A head-count that follows the manifest is adopted, not refused (INV-042). The check applies when a plan is created or its dates change.

INV-042 · A meal plan follows the manifest

Status: DECIDED (2026-09-24) · Owner: Operations · Source: the owner, on how catering is bought and who eats

A departure's catering is not a number typed once. It is the departure's passenger list, priced.

  • The unit is meal-days. A catering contract is bought as pilgrims × days (FoodInventory."totalQuantity"), because that is how a caterer bills. A departure of 40 for 10 days draws 400. capacityPerDay is a real ceiling: no single day may ask the caterer to feed more than it.
  • The head-count follows the manifest. A meal plan is manifest by default: fed and billed are derived from the live passenger list (INV-040's definition, now inv_group_meal_headcount() in the database) and re-derived whenever a pilgrim joins, cancels, transfers, opts out of meals, or changes category — and whenever an exclusion box is flipped. The stock moves with it, and so does the cost voucher, in the same transaction. An operator who types a head-count makes the plan fixed; it then stays until they change it.
  • The cost voucher is the database's. Dr Food Expense (5300) / Cr Stock-in-Hand (1310) for mealRatePerDay × mealDays × billed, in rupees at the contract's rate, pending a second person's approval (FIN-032). When any of those moves, the live voucher is reversed and a fresh one posted. A voucher that already says the right amount is left alone. A screen does not build this voucher, so a clone, a transfer and a cancellation post correctly whether or not anyone is looking.
  • A refused posting never blocks the plan and is never swallowed (FIN-030). A closed period or a missing ledger head leaves the plan standing and queues the refusal in Unposted entries with the head-counts on it. A foreign-currency contract with no rate posts nothing — never riyals as rupees (FIN-034) — and queues the same way; setting the rate on the contract posts every plan on it.
  • Growth from the manifest is adopted, not refused. A pilgrim who has booked is coming whether or not the caterer has been told. When the manifest pushes a contract over what it bought, the contract is named as over (inventory_over_allocated_meal_contracts(), FoodInventory."oversoldMealDays", and on the Food and Groups screens) and a person buys more or moves people. A typed plan, a contract shrunk under its allocation, or anything else that would make an overage worse is refused (INV-012); an over-allocated contract may always be brought down.
  • One plan per contract per departure. Assigning twice drew the contract down twice and posted the cost twice.
  • A contract is a purchase. It names its caterer, its rate per pilgrim per day and its daily capacity, and writes the supplier's bill, so the caterer's ledger shows what is owed. A CSV import follows the same rules or the row is refused.

Enforced by (20260927110000_a_meal_plan_follows_the_manifest.sql; supabase/tests/a_meal_plan_follows_the_manifest.sql, src/lib/api.foodAssignmentCost.test.ts): inv_group_meal_headcount(), inv_refresh_group_meals() from triggers on BookingPassenger and Booking, inv_repost_food_consumption() from triggers on GroupFoodAssignment and FoodInventory."exchangeRate", recompute_food_counters() in meal-days with the "worse vs. putting right" guard and inv_food_day_breach(), the unique index on (groupId, foodId), and PATCH /food/assignments/:id. From the native app: create_food_inventory (a contract is a purchase — validated in the route's words, the purchase voucher and the caterer's bill posted by inv_post_food_purchase_as), update_food_inventory (refuses quantity), assign_food_plan (fed / billed from inv_group_meal_headcount, a typed plan checked against the contract, one plan per contract) and unassign_food_plan (reverses the consumption voucher, returns the meal-days) — 20260929030000_food_and_transfers_post_their_own_purchase.sql, tested in supabase/tests/food_and_transfers_post_their_own_purchase.sql.

Before this, the head-count was frozen at the moment of linking. Linked before bookings existed — the normal order of work — it was 0, and at 0 the stock check was skipped, the per-passenger gate switched itself off and no cost voucher posted: forty people were fed against a contract that read "0 consumed, ₹0". The screens counted heads, the counter's own message said meal-days, the edit dialog wrote the free stock back as the purchase on every save, and the Groups page listed every departure's meal plans with a working Delete on each.

INV-041 · Ground transport capacity

Status: PROPOSED · Owner: Operations A passenger can only be assigned transport that belongs to their group, within the vehicle's seat capacity (infants excluded, PAX-010). Enforced by: Partly — 20260920100000_inventory_counter_integrity.sql: GroundTransferInventory.availableCapacity is derived from the group assignments and recompute_ground_counters() refuses an allocation past capacity (it was previously set once at insert and never maintained, while api.ts did an unlocked read-modify-write). The per-passenger half — that a passenger may only be put on transport belonging to their own group, and that infants do not take a seat — is not enforced: capacity is counted at group level from GroupGroundTransferAssignment.quantity, not from BookingPassengerGroundService. Test: supabase/tests/inventory_integrity.sql. From the native app the seats and the expense voucher are one transaction: assign_ground_transfer / unassign_ground_transfer (20260929030000_food_and_transfers_post_their_own_purchase.sql, inventory.edit) — the browser's createGroundTransferAssignment posting, derived in the database; test supabase/tests/food_and_transfers_post_their_own_purchase.sql.