01 · Customer lifecycle
How a customer moves from first enquiry to a finished (or cancelled) trip, and what every other module must do at each step.
Stages
LC-001 · One journey, one set of states
Status: PROPOSED · Owner: Operations A booking is always in exactly one of these states:
DRAFT → PENDING_OPS → PENDING_FINANCE → CONFIRMED → IN_TRAVEL → RETURNED → COMPLETED
with side exits NEEDS_CORRECTION (returns to the stage that sent it back), REJECTED (finance refused; terminal unless resubmitted), PARTIALLY_CANCELLED, CANCELLED (terminal), TRANSFERRED (every traveller transferred out; terminal and read-only — LC-031).
Only the transitions listed in LC-002 are allowed. Any other change is refused by the database.
Enforced by: 20260918120000_booking_lifecycle.sql — existing enum values; APPROVED is shown as CONFIRMED. IN_TRAVEL / RETURNED / COMPLETED arrive in Wave 5.
LC-002 · Allowed transitions
Status: PROPOSED · Owner: Operations
| From | To | Who | Condition |
|---|---|---|---|
| DRAFT | PENDING_OPS | Sales | Required passenger details complete (PAX rules) |
| PENDING_OPS | PENDING_FINANCE | Operations approver ≠ creator | Inventory available |
| PENDING_OPS / PENDING_FINANCE | NEEDS_CORRECTION | Approver | Reason required |
| NEEDS_CORRECTION | the stage that sent it back | Sales | Correction noted |
| PENDING_FINANCE | CONFIRMED | Finance approver ≠ creator | Payment policy met |
| PENDING_FINANCE | REJECTED | Finance approver | Reason required |
| REJECTED | PENDING_FINANCE | Sales | Corrected and sent back to finance (LC-005) |
| CONFIRMED / PARTIALLY_CANCELLED | IN_TRAVEL | System | Departure date reached |
| IN_TRAVEL | RETURNED | System | Return date reached |
| any state but CANCELLED | TRANSFERRED | System | The last traveller on the booking is transferred out (LC-031). No way back: a traveller who returns is transferred again, to a new booking |
| RETURNED | COMPLETED | Operations | Closeout checklist done (LC-041) |
| any pre-travel state | PARTIALLY_CANCELLED / CANCELLED | Cancellation flow only (LC-020) | — |
Editing a booking can never set its status directly; status changes only through these actions.
Enforced by: 20260918120000_booking_lifecycle.sql: Booking_lifecycle_guard trigger refuses any status change from a browser session and any transition outside this table (bl_transition_allowed); transitions run only in submit_booking, ops_decide_booking, finance_decide_booking, resubmit_booking (returns to sentBackFrom) and apply_booking_cancellation_status. New bookings start DRAFT (create_booking). REJECTED returns to finance through resubmit_booking (LC-005); ON_HOLD has no entry transition (LC-OPEN-1). "Payment policy met" is not yet checked (PRC-010 OPEN). Tests: supabase/tests/booking_lifecycle.sql, src/lib/api.bookingLifecycle.test.ts.
LC-003 · Passenger state
Status: PROPOSED · Owner: Operations
Each passenger has their own state: ACTIVE, CANCELLED, TRANSFERRED_OUT, TRAVELLED, NO_SHOW. Booking state is derived from its passengers where relevant (all cancelled ⇒ booking CANCELLED; some ⇒ PARTIALLY_CANCELLED).
Enforced by: Partly — 20260918120000_booking_lifecycle.sql derives booking counts from ACTIVE passengers and apply_booking_cancellation_status sets CANCELLED / PARTIALLY_CANCELLED from passenger rows. Passenger states beyond cancelled (TRANSFERRED_OUT, TRAVELLED, NO_SHOW) are not modelled yet.
Before the booking
SAL-001 · Why a lead or a quotation was lost
Status: DECIDED (2026-09-23) · Owner: Management A lead that is lost or closed, and a quotation that is rejected or expired, must say why. The reason is typed by the person ending it, at the moment they end it, and is stored on the record itself — not only in the event log — because the list, the detail dialog and every report read the record.
A lead now has a word for going nowhere: lost, alongside new, contacted, qualified, converted and closed. Before this there was none, so a dead enquiry was filed as "closed" and looked the same as one that had been worked to a finish.
Three things are kept each time: the reason (3 characters or more, the same standard as every other reason in the system), when, and who. Who is stored as a user id and shown as a name — an unattributed reason cannot be asked about.
Re-opening clears all three. A lead being worked again is not a lost lead, and a quotation back in play is not a rejected one; a reason left behind reads as a live one.
Every status change on a lead or a quotation is recorded in the audit trail, lost or not: the record holds the latest answer, the trail holds the history of answers.
What this does not do: it does not offer a fixed list of loss reasons, so the answers cannot yet be counted by cause — only read one at a time. A reason vocabulary is a business decision (SAL-OPEN-1).
Enforced by: 20260926170000_why_a_lead_or_a_quotation_was_lost.sql adds Lead.lostReason / lostAt / lostBy and Quotation.rejectionReason / rejectedAt / rejectedBy, each commented with this rule. src/lib/api.ts — PATCH /leads/:id refuses lost or closed without a reason, PATCH /sales/quotations/:id refuses rejected or expired without one, both store the three fields, clear them on re-open and write a status_changed audit entry. Lead.status deliberately has no CHECK constraint (it never had one), so the allowed words live in that handler. This is a completeness rule, not a security boundary, so the browser handler is the right place for it. The staff app has no browser handler, so it changes a lead's status through lead_set_status (20260930180000_the_staff_app_works.sql), which makes the same checks in the database: leads.edit, a reason for lost or closed stored with who and when, cleared on re-open, a converted lead kept converted. Tests: src/lib/api.lostReasons.test.ts, src/services/leadService.test.ts, src/services/quotationService.test.ts, supabase/tests/the_staff_app_works.sql.
SAL-002 · An enquiry keeps the names it collected
Status: PROPOSED · Owner: Management The public enquiry form asks for every traveller — name, passport number, date of birth, gender, phone. Those names are stored on the lead and shown to the staff member who opens it. Asking a customer for twelve names and keeping none of them is the same as not asking, and it makes staff ask again on the phone.
They are stored as what they are: unverified text a stranger typed on a public form. They are not Passenger records, hold no seat, bed or visa case, and are not matched to a customer. Passenger records are created when the lead converts to a booking, from the data entered there.
A traveller without a first name is refused rather than stored blank — the name is the whole point of the list. Everything else is optional. An enquiry lists at most 20 travellers, which is what the form offers; every field is trimmed and length-capped, because nothing an unauthenticated form sends can be trusted (ACC-003).
The passport number is shown on the leads screen by its last 4 characters only: a lead is not the passenger record (AUD-020).
Converting a lead to a booking carries them (bookings audit, 01/10/2026; POST /leads/:id/convert, src/lib/leadConversion.ts, tested in src/lib/api.bookingFlows.test.ts). The booking is made with the lead's customer first and then every other traveller the enquirer named, as a quotation conversion does; the traveller on the list who is the customer (the same passport or the same name) is not booked twice, and what they typed fills in what the customer record lacks (a lead's LEAD-… placeholder is not a passport). The price booked is the price after the discount — what the quotation records as its final total — not the price before it. The travellers are passengers like any other: create_booking checks them, the booking starts as a draft and goes for approval.
What this does not do: it does not de-duplicate a named traveller against existing customers (SAL-OPEN-2).
Enforced by: 20260926190000_a_lead_keeps_the_names_it_collected.sql adds Lead.passengers (jsonb, default []) with a CHECK that it is a JSON array of at most 20 entries. supabase/functions/lead-intake/validate.ts trims, caps and refuses the list; the edge function is the enforcement point because the form is public. src/pages/leads/Leads.tsx lists the names on the detail dialog. Tests: src/services/leadIntakeValidation.test.ts, src/services/leadService.test.ts, supabase/tests/lead_keeps_its_passengers.sql.
Approvals
LC-004 · Nothing is bought before the booking is confirmed
Status: DECIDED (2026-09-22) · Owner: Management Work that commits the company's money to a traveller waits until finance has confirmed their booking (CONFIRMED, shown as APPROVED): applying for a visa, giving them a hotel bed, a meal plan or a coach seat, taking a seat out of inventory, and issuing a ticket number (AIR §24).
While a booking is anything else — draft, waiting for operations or finance, sent back for correction, rejected — the system refuses that work and says which booking and why.
What stays allowed on an unconfirmed booking, and must: correcting the booking, adding or fixing passengers, passports and documents, and receipts, because money that has arrived has to be recorded (FIN-042). Payments out wait for confirmation.
A confirmed booking cannot be sent back for correction (LC-002), so the check sits where the service is first given, not on every later edit of it.
Enforced by: 20260926100000_nothing_is_bought_before_confirmation.sql — booking_not_confirmed_reason() is the single answer; require_confirmed_booking() raises with it; BEFORE INSERT triggers on VisaCase, BookingPassengerHotel, BookingPassengerFlight, BookingPassengerMeal and BookingPassengerGroundService refuse browser writes. Database flows (the cancellation chain, transfers, backfills) are not affected. Screens ask booking_services_gate() so a button can be greyed out with the reason. Test: supabase/tests/nothing_before_confirmation.sql.
LC-005 · A rejected booking can be put right
Status: DECIDED (2026-09-23) · Owner: Management · Settles LC-OPEN-2 A rejection is a stronger send-back, not a dead end. Finance refuses a booking with a reason; sales corrects what was objected to and sends it back to finance, who refused it — never to operations, who had already passed it. Nobody retypes a customer, their passengers and their price because one thing was wrong.
A refused booking gives its seats back to the departure at the moment it is refused, and takes them again when it is sent back for approval — which re-checks capacity, so a departure that filled up meanwhile refuses it there and then rather than quietly overbooking.
Sending it back for approval still runs every readiness check: an incomplete booking is refused, naming what is missing.
Enforced by: 20260926160000_a_rejected_booking_can_be_put_right.sql — REJECTED → PENDING_FINANCE added to bl_transition_allowed and reachable only through resubmit_booking; a rejection writes its reason to BookingCorrection (where a send-back writes its own, so one screen shows either); bl_group_capacity_count excludes rejected bookings and the booking trigger recounts on a status change. Test: supabase/tests/rejected_booking.sql.
Since 20261007100000_booking_db_guards.sql, resubmit_booking checks capacity after the status change, when the booking's travellers count again (before, a rejected booking was checked while it did not count, so it could overbook a departure that had filled up), and refuses a rejected booking on a departure whose sales are stopped (INV-006). A rejected booking is still with sales: its price and travellers may be changed before it goes back (PRC-003). Test: supabase/tests/booking_db_guards.sql.
LC-006 · A customer record is made by a booking, not on its own
Status: DECIDED 2026-09-23 (owner) · Owner: Management The owner's instruction: do not add a customer to the database directly unless a booking is created — a customer is registered when we create a booking. Somebody is in the customer list because they are travelling or are about to. Nobody is entered "for later".
This is a data-protection rule as much as a housekeeping one. A customer row carries a passport number, a date of birth and contact details; collecting that without a purpose is holding it without a reason (INT-006, DPDP minimisation). A directory of people with no booking is also where duplicates and stale phone numbers come from, and every one of them is a person somebody might ring by mistake.
Three paths create a Customer today:
| Path | Where | Is there a booking? |
|---|---|---|
| The booking flow | POST /sales/bookings → the wizard |
Yes — this is the intended path |
| The standalone screen | POST /sales/customers, gated on customers.create |
No |
| Visa enquiry conversion | convert_visa_intake() — matches on passport, otherwise inserts |
No |
| First booking request of a customer who signed up (email, TRV-007, or WhatsApp code, TRV-016) | customer_create_request() → customer_record_for_login() |
A booking request — the record is made when the person books |
An old client still calling self_register_customer() |
→ customer_record_for_login() |
No — kept so an old cached page keeps working; sign-up no longer calls it |
Not enforced yet for the office's paths. The standalone route and the "Create customer" button still exist, and the visa conversion still creates a customer before any booking. Removing the standalone path is a separate change, because the visa desk depends on creating a person before a booking exists (LC-OPEN-4). Public sign-up is settled (LC-OPEN-5, below): it makes a login only.
Enforced by (public sign-up only): auth_signup_create_login() (WhatsApp) and auth_customer_login_create() (email, and customer-exchange) write the User row and the CUSTOMER role and never a Customer row; the record is made by customer_record_for_login() from customer_create_request() on the first booking request, or from self_register_customer() for an old client — always with a SIGNUP-… placeholder passport, never a blank one (migrations 20261005110000, 20261005120000; tests supabase/tests/a_customer_signs_up_with_a_whatsapp_code.sql, supabase/tests/an_email_sign_up_is_a_login_only.sql). Existing customer rows are not touched.
Note 2026-10-01 (TRV-016): a customer who signs up with a WhatsApp code gets a login only — owner: sign-up makes a login; the customer record is created when they make their first booking. The record is made by their first booking request (Book this trip) from the login's name, number and email, with no passport yet; the database refuses to make one at sign-up (auth_signup_create_login() writes no Customer row). Amended the same day for the email sign-up too (LC-OPEN-5, owner: "Yes"): every public sign-up is a login only. Until the first booking request the portal and the app read empty, say "Your details will be added with your first booking", and refuse any other request in those words.
LC-007 · A booking created is announced, once, from every door
Status: PROPOSED · Owner: Sales/Operations · Source: LC-002, TRV-010, WRK-010
When a booking exists, the office hears about it the same way whether it was made on the desktop, in the native app by staff, or by a partner in the app or on the web: every sales approver (everyone active who holds approvals.approve) gets a kept notification "New booking awaiting sales approval" that opens the booking, and a push to their phone, once the booking is with them (PENDING_OPS — a draft has nothing to approve yet); the customer gets the "booking received" e-mail and a partner gets the "booking placed" e-mail. The announcement happens once per booking: a retry, a double tap or a second door never tells anyone twice. It is made by the booking's creator or the partner whose agency owns the booking, by nobody else, and it never undoes the booking — if it fails, the booking stands and is in Approvals regardless. The e-mails are queued and leave when the dispatcher runs (WRK-010: one mail path, no second one).
Enforced by: booking_created_notify(p_booking_id) (20260929110000_a_booking_created_is_announced.sql; SECURITY DEFINER; the creator holding bookings.create or auth_agent_id() = the booking's agent; refused to anon; advisory-locked and idempotent per booking — inbox rows keyed on sourceRef + kind approval, queue rows on payload.bookingId + messageType); approvers from bcn_approver_ids() (role grants ∪ explicit user grants, minus explicit denies and inactive logins, through user_has_permission; service_role only). Rows: AppNotification (TRV-010) and CommunicationQueue (booking_created to the customer, b2b_booking to the partner, payload.templateData rendered by the mailer through the communications-dispatcher). The push is push-send with store:false, called by whoever called the function: src/lib/api.ts announceBookingCreated() on POST /sales/bookings (after the submit; submitBookingForApproval does not push on that path) and POST /portals/agent/bookings; apps/mobile/src/lib/newBooking.ts announceBooking() in the staff wizard's submit and apps/mobile/src/lib/partner.ts createPartnerBooking(). Tests: supabase/tests/a_booking_created_is_announced.sql, src/lib/api.bookingLifecycle.test.ts, src/lib/api.portalIsolation.test.ts, apps/mobile/src/lib/newBooking.test.ts, apps/mobile/src/lib/partner.test.ts.
The e-mail leaves at once and again on a schedule: right after the announcement the caller kicks the communications-dispatcher with its own session (the dispatcher's "kick" mode sends only the pending rows that caller queued — createdBy — never the whole queue; src/lib/api.ts kickCommunicationsDispatcher(), apps/mobile/src/lib/newBooking.ts kickCommunicationsDispatcher()), and the pg_cron job alhuda-communications-dispatcher runs the full dispatch every five minutes (20260929110100_the_dispatcher_runs_every_five_minutes.sql: comms_dispatcher_run() posts through pg_net with the URL and key read from Vault as communications_dispatcher_url / communications_dispatcher_key — deploy runbook → Secrets; missing secrets log "not configured" and send nothing; where pg_cron cannot be created the migration says so and still succeeds). Test: supabase/tests/the_dispatcher_runs_every_five_minutes.sql.
Not built: an announcement for a booking submitted later from a draft on the desktop (that path still pushes from the browser through submitBookingForApproval), and for a passenger transfer's new booking.
LC-010 · Maker-checker on every approval
Status: PROPOSED · Owner: Management
The person who created or last materially edited a booking cannot approve it at ops or finance stage. The approver recorded is always the signed-in user — never a name supplied by the screen. (See ACC-020.)
Enforced by: 20260918120000_booking_lifecycle.sql: Booking.createdBy stamped from the session (backfilled from the audit trail); ops and finance decision functions refuse the creator and record the approver from auth.uid(). "Last materially edited" is not tracked yet. Test: supabase/tests/booking_lifecycle.sql.
Cancellation
LC-020 · Cancellation is a flow, not an edit
Status: PROPOSED · Owner: Operations
Cancelling a passenger or booking always runs the cancellation flow, which in one all-or-nothing step: applies the cancellation policy (PRC rules), releases that passenger's seats, beds, meals and transport (INV-010), closes their visa case (VISA-020), posts the finance entries (FIN-030), updates group capacity, and records the event (AUD-001).
Enforced by: Partly — any pre-travel booking (PENDING_OPS, PENDING_FINANCE, NEEDS_CORRECTION, ON_HOLD, APPROVED, PARTIALLY_CANCELLED, REJECTED) enters the cancellation flow; every cancellation is a request approved by a different user (trigger on Booking/BookingPassenger cancellationApprovedBy). The request is written only by request_booking_cancellation / request_passenger_cancellation, which record the signed-in user and the time as the requester; a browser write of a request, or of who asked, is refused, and a request that names nobody cannot be approved — it is asked for again (20261007100000, ACC-020). A traveller's cancelledAt is never cleared or changed from the browser, and is stamped only while another person's request is being approved. Seat/service release is release_passenger_services. A whole booking's approval is one database transaction since 20261008140000_booking_screens.sql: approve_booking_cancellation() approves every active traveller through approve_passenger_cancellation(), writes the booking's approval and derives its status (apply_booking_cancellation_status) — a refusal for any traveller leaves the booking rows and all of them as they were, and a second call returns the first. Because the steps around it cannot be rolled back, the route first calls begin_booking_cancellation_approval(): it runs the approval with the same refunds and rolls it back, so any refusal comes back before a service is released or a credit note issued, and it claims the approval for five minutes, so a second click is refused before it releases anything again. The database keeps one live credit note per traveller (unique index GroupInvoice_one_credit_note_per_passenger). What can still go wrong: an unpriced traveller's refund is set from their credit note after the dry run, so the final approval can still refuse it — the booking then stays pending with its services released and its credit note issued. The rest is still separate steps run from src/lib/api.ts, so the whole flow is not yet one transaction: the release runs before it (its own transaction, and the permission gate), the group-invoice credit notes before it, and the journal reversal and the retention vouchers after it (a failure there is recorded as a posting failure, FIN-030). Test: supabase/tests/booking_screens.sql.
Rejecting a request. On the website a rejection is still a browser write to Booking / BookingPassenger: the write guards check only that a pending request is the one rejected, and bookings.cancel.approve is checked in the browser. On the phone (#559 step 4) the rejection is app_reject_cancellation() (20261009110000_app_bookings.sql), which checks bookings.cancel.approve itself, needs a reason of three characters or more, stamps who rejected it and when, audits it as the website does (cancellation_rejected / passenger_cancellation_rejected), and leaves a note in the requester's inbox; a second call changes nothing. Approving stays on the website. Test: supabase/tests/app_bookings.sql.
LC-021 · Deleting a booking
Status: PROPOSED · Owner: Management
Bookings are never physically deleted. "Delete" is only allowed for DRAFT bookings with no payments; everything else must be cancelled.
Enforced by: 20260918120000_booking_lifecycle.sql: delete_booking allows only DRAFT bookings with no payments (soft delete, reason recorded, allocations released); browser sessions cannot set deletedAt. UI asks for the booking number (UX-001). Test: supabase/tests/booking_lifecycle.sql.
Since 20261007100000_booking_db_guards.sql: delete_booking also refuses a draft with money carried into or out of it (LC-032), a traveller transferred into it, a visa case, or a ticket with a number — cancel it instead; in the same transaction it rejects every pending voucher of the booking and reverses every approved one (bl_booking_reverse_vouchers, through fin_post_reversal_as — left pending for a checker unless the person deleting may approve journals and did not make the voucher); a voucher already partly reversed refuses the delete. A browser session cannot hard-delete a booking at all. Test: supabase/tests/booking_db_guards.sql.
LC-022 · Customer cancellation ≠ airline cancellation ≠ seat release
Status: DECIDED (airline spec §19) · Owner: Ticketing Customer cancellation, passenger cancellation, airline block cancellation and seat release are four different transactions with their own records and finance entries.
Transfers
LC-030 · Passenger transfer between groups
Status: DECIDED 2026-09-17 · Owner: Operations
Transferring a passenger releases everything they held in the old group, assigns equivalent inventory in the new group where capacity exists, and puts anything that could not be matched on an operations "needs assignment" list. Their visa case, tickets, invoice lines and payments follow them. Group capacity is checked on the new group before the transfer.
Enforced by: Partly — booking-level group moves use move_booking_to_group (20260918120000_booking_lifecycle.sql): target capacity checked, old-group seats/rooms/meals/transport released to the register without cancelling passengers, tickets or visa. Automatic re-assignment in the new group and the "needs assignment" list are not built.
Passenger-level transfers (POST /sales/bookings/:id/passengers/:pid/transfer) move the passenger and their money in one database step, transfer_passenger_to_booking() (20260924130000_transfer_price_override.sql, redefined in 20261002090100_a_transfer_keeps_its_records.sql): the passenger lands at the price they were sold at, or at a new price with a reason (PRC-005); both booking totals move by exactly what the receivable voucher moves; the money received for them is carried with them (LC-032); the transfer is recorded in BookingPassengerTransfer. The booking they land on keeps the old booking's identity (LC-033); a booking left with no travellers is closed (LC-031). Flight seats on a block or FIT the new group also flies on move with the passenger; other seats are released to the register. The passenger's visa case and ticket records follow them — inside transfer_passenger_to_booking() since 20261008130000, so both doors (the web route and the app's transfer_traveller_to_group()) move them in the same transaction as the traveller, and a failure undoes the transfer. GST and agent commission do not follow them — see PRC-005.
LC-031 · A booking emptied by transfers is closed as TRANSFERRED
Status: DECIDED 2026-09-28 · Owner: Operations · Source: owner — "when a booking is transferred to other group, it makes the previous one fully paid automatically. If booking is transferred, the previous booking should clearly show transferred sign outside on the list, and should make it uneditable, and show turn it to read-only"
When the last traveller on a booking is transferred out (either transfer path), the booking becomes TRANSFERRED — a status of its own, never "paid" and never "cancelled". It records when (transferredAt), the booking the last traveller went to (transferredToBookingId) and every booking its travellers went to (transferredToBookingNos). Its total, paid and balance are all 0, because the travellers and their money went with them (LC-032).
- The lists — web bookings list, customer 360, the partner portal, the staff and partner apps — show Transferred → BK-… in place of the payment state. A booking that lost some travellers stays open and shows N transferred out → BK-….
- Read-only, in the database. No change to the booking or its travellers, no traveller added or moved in, no status change, no move to another group, no delete, no payment recorded or changed, no allocation, no refund. Every refusal says "BK-… was transferred to BK-…; it is read-only." Writing a note or the receipt file on a payment received before the transfer is allowed (a receipt can still be printed). Maintenance that must change one sets
app.transferred_booking_maintenancefor its transaction (runbook). - Before it closes, the transfer of the last traveller is refused while a payment on the booking waits for verification or a refund on it waits for approval — decide those first, so no money is left behind on a closed booking.
- No undo. A traveller who comes back is transferred again, to a new booking.
- Only a live booking takes a traveller in (
20261008130000). The target may not be CANCELLED, REJECTED or TRANSFERRED, nor a DRAFT other than the booking made for this transfer — "BK-… is a draft that has not been sent for approval; a traveller is not transferred into it." Transfer them to a new booking on the group instead. - A cancellation waiting is decided first. A traveller whose cancellation request is pending, or whose booking's is, is not transferred — on either door.
- "The last traveller" is the last ACTIVE one. A cancelled traveller left on the booking does not keep it open; their cancellation and its credit stay on it.
- The trail. Every move writes
BookingPassengerTransfer— into a booking made for the transfer or one that already existed — and the old booking'stransferredToBookingNos.booking_transfer_trail(booking)lists each move in or out: who, from which booking and group, to which, the prices, the reason, when and by whom. The booking page shows it as Transfers, and each traveller who came in says Transferred from BK-…; the group's traveller list says Transferred from BK-… · group on the receiving side and Transferred to group (BK-…) under Left the group; the phone booking screen lists the transfers; Customer 360's journey says "Transferred from BK-A (group) to BK-B (group) on …" for the traveller and "All travellers transferred out to BK-…" for the emptied booking.
Enforced by: 20261002090000_booking_status_transferred.sql (the status), 20261002090100_a_transfer_keeps_its_records.sql (the close inside transfer_passenger_to_booking(), triggers on Booking, BookingPassenger, Payment, PaymentAllocation, PaymentRefund). Test: supabase/tests/transfer_keeps_records.sql. Bookings emptied before 2 Oct 2026 are closed by the migration's backfill (bkg_backfill_transferred_bookings()), which moves no money — see LC-032.
LC-032 · The money follows the traveller
Status: DECIDED 2026-09-28 · Owner: Finance · Source: owner — "the new booking I think loses its record, for example payment record"; LC-030 ("payments follow them")
The money received for a transferred traveller is carried to the booking they land on: a BookingPaymentCarry row says "this much of what BK-A received now counts on BK-B". No payment row is moved, split or deleted; the payment stays where it was received, with its receipt. Paid, balance, what can be refunded (fin_refundable) and verified-paid (customer 360, finance cleared, dues and readiness) count the carries on both bookings. Both booking pages show the carry: Payment carried from BK-A on the new one, Carried to BK-B on the old one.
- How much. The travellers who stay are paid for first: the old booking keeps what it still bills (its total, plus GST, less cancellation credit), and the traveller takes what is left, up to the price they were sold at. The last traveller out takes everything left, so a closed booking holds no money — unless a cancelled traveller on it has a cancellation credit (a refund owed or paid): then the last traveller also takes at most their own price, and the refund stays with the booking it is owed from (PRC-030). Example: two travellers at 1,00,000, 1,50,000 received. One moves: the old booking keeps 1,00,000 (paid in full), the new booking gets 50,000 (50,000 due). Both move: the new booking gets all 1,50,000.
- The books. When both bookings bill the same party ledger (the same partner, or the same customer or payer), nothing is posted — the receipt already sits on that ledger. When they differ, a
booking_adjustmentvoucher moves the credit (Dr the old party / Cr the new party), tagged to the new departure (FIN-035) andpendingfor a second person (FIN-032). - Past transfers are not re-pointed automatically. A booking emptied before 2 Oct 2026 is closed as TRANSFERRED but keeps the money it received; carrying it to the new booking is a finance task, listed in the runbook.
OPEN (owner): is "the travellers who stay are paid for first" the split you want for a part transfer? The alternatives are pro-rata by price, or "the traveller who moves takes their price first". Until decided, the rule above stands: it never leaves the old booking overpaid-looking, and the new booking's due is explained by the carry line.
Enforced by: transfer_passenger_to_booking(), fin_booking_receipts_total(), fin_refundable(), tvc_verified_paid() in 20261002090100_a_transfer_keeps_its_records.sql. Test: supabase/tests/transfer_keeps_records.sql.
LC-033 · The new booking keeps the old one's identity
Status: DECIDED 2026-09-28 · Owner: Sales · Source: owner — "customer is direct or via business partner"
A booking made for a transferred traveller (transfer_target_booking()) is made out to the old booking's customer and payer — not the traveller's own customer record — with its business partner and channel (direct or through a partner, and so the partner's commission terms), its sales owner (createdBy), its emergency contact, and "Transferred from BK-…". Travellers of one booking moved to the same group — one by one, from the bulk dialog, or from the app — land on one new booking, not one each.
A traveller keeps the channel they were sold through: moving a partner's traveller into an existing direct booking, a direct traveller into a partner's booking, or one partner's traveller into another partner's booking is refused — transfer them to a new booking on the group instead. Moving a direct traveller into another customer's direct booking (a relative's) is allowed; the money carried with them moves ledgers with a pending voucher (LC-032).
OPEN (owner): (1) should a manager be able to override the channel refusal with a reason? Not built — refused for everyone. (2) The sales owner of the new booking is the original seller, so the transfer does not count as a new sale for the person who pressed Transfer; confirm. (3) The new booking is the booking customer's, even when the traveller moving alone has a customer record of their own; confirm.
Enforced by: transfer_target_booking() and transfer_passenger_to_booking() in 20261002090100_a_transfer_keeps_its_records.sql, re-created in 20261008130000_booking_flows.sql; used by the web route and by transfer_traveller_to_group(). Test: supabase/tests/transfer_keeps_records.sql, supabase/tests/booking_flows.sql, src/lib/api.transferPriceOverride.test.ts.
LC-034 · A confirmed traveller who is transferred stays confirmed
Status: DECIDED 2026-10-01 · Owner: Operations / Finance · Source: owner, decision (T): "a CONFIRMED traveller transferred to another booking or group lands CONFIRMED, with no re-approval, when the price and channel are unchanged. A price change on transfer still needs finance approval by someone other than the person transferring."
A traveller on a confirmed booking (APPROVED, or PARTIALLY_CANCELLED) who is transferred at the price they were sold at and through the same channel (direct, or the same business partner — LC-033) lands on the booking made for the transfer, and that booking becomes APPROVED at once: it is not sent to operations and finance again. This holds while every traveller on that booking came the same way, and the booking bills exactly what the transfers brought (a draft re-priced before the move goes through approval); a later traveller moved in at a changed price does not undo it.
A traveller moved at a changed price keeps the approval chain: the booking made for them is sent for approval like any new booking (LC-002), and finance approval refuses the person who made that transfer — "Maker-checker: you transferred a traveller into BK-… at a changed price, so another finance approver approves it." Into a booking that is already confirmed, the difference posts as its own voucher, pending a second person (FIN-032), who may not be the person who transferred.
A traveller who came to a booking by a transfer cannot be removed from it by editing the booking — that would delete the transfer record and the money it moved; cancel them, or transfer them on.
The booking's balance counts its price once the receivable moved into it is approved (FIN-033); when the old booking's price was never in the books, the booking that lands confirmed posts its own booking voucher, pending.
Enforced by: transfer_passenger_to_booking() and finance_decide_booking() in 20261008130000_booking_flows.sql; the status change is recorded in BookingStatusHistory (TRANSFER_LANDED_CONFIRMED, by "system" for the approvals). Test: supabase/tests/booking_flows.sql, src/lib/api.transferPriceOverride.test.ts.
LC-035 · A customer is in a group once — counting live bookings only
Status: DECIDED 2026-10-01 · Owner: Sales · Source: owner, decision (R): "only LIVE bookings count as 'already in this group' — CANCELLED, REJECTED and TRANSFERRED don't — and transfers follow the same rule."
A customer may hold one live booking on a departure. A booking that is CANCELLED, REJECTED or TRANSFERRED no longer counts, so a customer whose booking was cancelled can book the departure again. A transfer that would make the customer a second live booking on the group is refused, naming the booking to transfer into instead.
Enforced by: bl_booking_is_live(); create_booking(), update_booking() and transfer_target_booking() in 20261008130000_booking_flows.sql; the edit screen's early message in PATCH /sales/bookings/:id. Test: supabase/tests/booking_flows.sql, src/lib/api.bookingFlows.test.ts.
LC-036 · A booking is made only on what exists and is open
Status: DECIDED 2026-10-01 · Owner: Sales · Source: bookings audit (engineering, on the standing rule that booking rules live in the database)
create_booking refuses a group that is closed (isActive off) or has departed (its departure date is before today in India), as the partner and move paths do — a booking on the departure day itself is allowed on every path; a currency outside the list the system supports (INR, SAR, USD, AED, GBP, EUR, PKR, BDT, JPY, BHD, KWD, OMR, QAR, MYR, TRY, EGP); and a business partner or payer that does not exist. A request sent twice with the same client id makes one booking: the second waits for the first and returns it (the booking wizard sends one id per session). An id that belonged to a deleted booking is refused.
Enforced by: create_booking() patched in 20261008130000_booking_flows.sql; partner_create_booking() checks its own double-submit signature under a lock. Test: supabase/tests/booking_flows.sql.
Travel and closeout
LC-040 · Automatic travel states
Status: DECIDED 2026-09-17 · Owner: Operations On the departure date bookings move to IN_TRAVEL; on the return date to RETURNED. This runs automatically every day.
LC-041 · Closeout checklist
Status: DECIDED 2026-09-17 (checklist contents PROPOSED) · Owner: Operations A booking or group becomes COMPLETED only when operations confirms: no-shows recorded; visa cases closed; inventory reconciled (used / unused / released); supplier costs posted; customer balance settled or listed as outstanding. Completion triggers revenue recognition (FIN-001) and locks the booking against edits (changes then require a correcting entry).
LC-042 · A departure stays reachable after it flies
Status: DECIDED 2026-09-19 · Owner: Operations
A group is never unreachable because of its dates. The group list may hide a flown departure — that is a filter the user can switch (Active / Archived / All Groups), not a permanent exclusion — but opening one departure by its id always works, whatever its departure and return dates and whether or not it has been closed. Most of the work on a departure happens after it returns: reconciling the cost, writing off unsold seats, approving cancellations, answering the accountant.
Enforced by (src/lib/api.ts, src/lib/api.groupDetail.test.ts): GET /groups/:id reads the group by id with no date filter of any kind, needs groups.view, and returns what the detail page needs — the group, hasDeparted / isArchived so the screen can label it rather than hide it, and the cancellation policy it is on with its bands and kept items (PRC-020). A customer or partner gets the public shape of a departure they hold a booking or quotation on, or one on open sale, and nothing else (ACC-010) — the TravelGroup select portal policy is the boundary, not the handler.
Before this the route did not exist: every role got 404 Unknown groups route: GET /groups/<id>, and GET /groups defaults to the active scope, which drops a departure once its return date has passed. The detail page could only find a group by listing every group in every scope and searching the array in the browser, and the 29 Aug 2026 departure — the one the whole accounting audit is about — could not be opened by its own URL.
Open questions
- LC-OPEN-1 Can a CONFIRMED booking be put ON_HOLD (e.g. payment dispute)? The database has ON_HOLD; no process uses it.
- LC-OPEN-3 Lead and quotation expiry: how long is a quotation valid, and does it hold inventory?
- LC-OPEN-4 A visa enquiry converts into a visa case, and
convert_visa_intake()creates the customer, before any booking exists. Is a visa case on its own a good enough reason to hold somebody's record, or must a booking come first (LC-006)? - LC-OPEN-5 Public sign-up: somebody registers on the website with no booking. Do they become a
Customerrow at sign-up, or only an account that becomes a customer when they book (LC-006)? DECIDED 2026-10-01 (owner). Asked "Should the email sign-up also become login-only, like WhatsApp, with the customer record at first booking?" — "Yes". Every public sign-up (email, TRV-007; WhatsApp code, TRV-016) makes an account only; theCustomerrow is made by the first booking request (LC-006). - SAL-OPEN-2 Should a named traveller be matched against existing customers (by passport, by phone)? Converting a lead carries the names it collected into the booking's passengers — DECIDED 2026-10-02 (owner: "Yes, copy them"): the enquirer first, then every named traveller, with passport and date of birth when typed; staff check them before the booking goes for approval (SAL-002). Still open: matching a named traveller to an existing customer record (by passport or phone) — staff do it by hand.
- SAL-OPEN-1 Should a lost lead or quotation pick its reason from a fixed list (price, dates, competitor, went quiet, not serious, …) as well as writing one in? A list makes the losses countable by cause; free text alone does not. Needs the owner to name the causes the business wants counted.