Skip to content

Bookings, Groups & Sales API

The sales and operations surface: booking CRUD + import + invoice, wizard quotations, travel groups, group-invoices, flight/hotel linking for passengers, and the ops/finance approval lifecycle.


Sales bookings

Handlers: handleSalesBookings at src/lib/api.ts:3730, handleSalesBookingsById at src/lib/api.ts:6108, handleSalesBookingsImport at src/lib/api.ts:3611, handleBookingInvoice at src/lib/api.ts:4420.

Route Method Permission Purpose
/sales/bookings GET (read-only, agent-scoped; booking_page refuses a caller with no booking permission) List bookings; agents see only their own. With ?paged=1 / ?cursor= / ?sort= / ?q=: one keyset page in one call to booking_list_page (PRF-010). The legacy ?page= envelope and the bare array still read table by table
/sales/bookings POST bookings.create Create booking with passengers, per-category rates, B2B seat reservation
/sales/bookings/import POST no explicit gate — delegates to handleSalesBookings('POST') which requires bookings.create Bulk import bookings (sync customer passports, auto-create Customer rows)
/sales/bookings/:id GET (read-only; row security decides) One booking + passengers + payments + customer/payer/group/agent + resolved flight labels, in one call to booking_screen. The customer and payer carry customerCode, the agent partnerCode (PTY-006); so do the bookings list rows (booking_list_page). ?screen=1 adds screen: everything else the booking page shows (PRF-010)
/sales/bookings/:id PATCH bookings.edit Update fields and the passenger roster. Price and travellers only while DRAFT, NEEDS_CORRECTION or REJECTED — otherwise 409 (PRC-003)
/sales/bookings/:id DELETE bookings.delete delete_booking: soft-delete of a draft; in the same transaction rejects its pending vouchers and reverses its approved ones (LC-021)
/sales/bookings/:id/cancel POST bookings.cancel { cancellationReason } (required). request_booking_cancellation records the request and the signed-in user as requester; returns the booking. 409 when a request is already pending (ACC-020)
/sales/bookings/:id/passengers/:pid/cancel POST bookings.cancel { cancellationReason } (required). request_passenger_cancellation; returns the passenger. 409 when one is already pending
/sales/bookings/:id/cancel/approve, /passengers/:pid/cancel/approve POST bookings.cancel.approve 409 when the pending request does not say who asked — it must be asked for again; 403 for the requester. The booking-level approval first calls begin_booking_cancellation_approval with the same arguments (a dry run of the approval, rolled back, then a five-minute claim — 409 with the approval's refusal, or when another approval is under way, before anything is released), then approve_booking_cancellation(p_booking_id, p_items, p_override, p_reason) once — p_items is [{passengerId, refundAmount, chargePercent}], one per traveller not yet cancelled — which approves every traveller, the booking and its status in one transaction (LC-020). 409 when a traveller's refund is refused or the travellers changed since the refund was worked out (nothing is cancelled); a repeated call returns the booking unchanged
/sales/bookings/:id/cancel/reject, /passengers/:pid/cancel/reject POST bookings.cancel.approve { rejectionReason? }. Writes the rejection (cancellationStatus rejected, who, when, why) from the browser; 400 when no request is pending. The phone calls app_reject_cancellation instead (below)
/sales/bookings/stats GET (read-only; booking_stats refuses a caller with no booking permission) The list tiles over the list's filters (PLT-051). Status counts, each the set its filter word lists: draft, pending (PENDING_OPS + ON_HOLD), pendingFinance, onHold, awaitingApproval (PENDING_OPS + PENDING_FINANCE + ON_HOLD), needsCorrection, confirmed, cancelled (CANCELLED only), partiallyCancelled, rejected, transferred, selectable; the badge counts opsPending, opsSendBack, financePending. Money, live bookings (not DRAFT, CANCELLED, REJECTED or TRANSFERRED) in INR only: valueCurrency: 'INR', totalValue, grossValue, paidValue, outstanding (the balance), awaitingDue (the agreed due of bookings finance has not approved, FIN-033); otherCurrencyBookings counts live bookings in another currency, which are not added in. passengers
/sales/bookings/:id/invoice GET (read-only) Generate invoice payload for the booking; customer.customerCode, payer.customerCode and agent.partnerCode name each party by its code (PTY-006). lineItems add up to totalAmount: when the booking's share of the costs is more than its price, one Package line at the price replaces the cost lines (PAX-034)
/sales/bookings/:id/passengers/:pid PATCH bookings.edit Update one passenger's PNR or passportNo. Refuses flightId — a seat is taken through …/flights, which checks the block has one left (INV-012)

GET /sales/bookings and GET /sales/bookings/:id — one call each

PRF-010. How it works and what it measured: A screen is one call.

The list (?paged=1&limit=&sort=&dir=&cursor=&withTotal=&q=&status=&financeStatus=&opsStatus=&groupId=&agentId=&type=&from=&to=) calls booking_list_page with the same arguments as booking_page plus p_with_total, and returns the same PagedResponse as before: { data, nextCursor, total?, totalIsEstimate? }. from / to as YYYY-MM-DD are Indian days, both included; an ISO instant is used as sent, to exclusive (Dates and order). The stats and bulk-resolve routes read them the same way. Each row is unchanged — the booking's figures (gross, live paid and balance from its payments), status upper-cased, the approvers' names, customer, group, agent, payer, quotation, passengers, payments. total is exact with withTotal=1, an estimate (totalIsEstimate: true) otherwise, and absent while a search is on. A caller with no booking permission gets 403.

Agreed due and awaiting approval (FIN-033, from 20261006110000). Every list row and the booking carry agreedDue — price + GST − receipts − cancellation credit (fin_booking_agreed_due()), whether or not the booking voucher is approved — and awaitingApproval — true while finance has not approved the booking voucher. balanceAmount stays 0 until then; screens show "Awaiting finance approval — ₹X will be due" instead of Fully paid. Both are null from a database without the migration. The phone and the partner screens read the same figures through the PostgREST computed fields booking_agreed_due and booking_awaiting_approval on Booking.

The booking returns the same object as before. With ?screen=1 it also carries screen:

Key What null when
passengerServices { passengers: [{ passengerId, name, isPrimary, cancelled, services }] } — as /passenger-services (PAX-034) no bookings.view
journal the booking's vouchers (booking, booking_gst, booking_commission, booking_adjustment, booking_adjustment_gst), newest first, up to 500, with totalDebit / totalCredit; no lines no finance.view
groupPricing the group's rate sheet, as GET /groups/:id/pricing no group, or no groups.view
emergencyContact as GET /field/emergency-contact/:id none recorded
fieldConsent as GET /field/consent/:id none recorded
issuedDocuments current e-ticket and voucher rows (id, kind, label, fileName, issuedAt) — (empty list)
corrections { corrections: [...] }, as /corrections no bookings.view
incidentBanner as GET /operations/incidents/banner?bookingId= the caller may not see it
journey as GET /journey/bookings/:id no bookings.view

A booking the caller may not read is null (404 for a portal login), as before. On a database without the two functions both routes fall back to the separate reads; screen then carries only the first three keys and the other sections load themselves.

POST /sales/bookings

Cite: src/lib/api.ts:3934.

Key inputs — customerId, groupId?, agentId?, bookingType: 'umrah'|'hajj', roomType, per-category rates (adultRate, childRate, infantRate) + counts, service rates (ticketRate, visaRate, hotelRate, mealsRate, groundRate), passengers: [], b2bOfferId?, paymentPolicy, quotationId?.

Booking-level roomType is the default for passengers added to this booking. Each entry in passengers[] may carry its own roomType — what that traveller was sold — so a DOUBLE couple and a QUAD family can sit in one booking (PAX-033); omitting it takes the booking's value.

Client id — the booking wizard sends id, one per wizard session. create_booking takes a lock on it and returns the booking it already made for it (existing: true), so a double click or a retry after a lost answer makes one booking; an id that belonged to a deleted booking is refused.

Work: 1. Compute totalAmount from per-category rates × counts (or fall back to explicit totalAmount). 2. The database numbers the booking: the Finance Settings prefix or BK, a hyphen, the next number (FIN-048). 3. Reject duplicate (same customer + same group, live bookings only — LC-035). create_booking also refuses a closed or departed group, an unsupported currency, and an agentId or payerId that does not exist (LC-036). 4. If b2bOfferId, check seat availability and decrement B2BFlightOffer.seatsAvailable atomically (matched-by-version UPDATE). Restore seats if subsequent steps fail. 5. Insert Booking, then BookingPassenger rows. 6. Audit + group history logs.

Partner credit limit (PTR-030). With agentId, the database refuses a booking that would take the partner over its credit limit (trigger "Booking_credit_limit"). The route answers 409 with { message: "This would take <agency> over its credit limit of ₹X (owed ₹Y). Ask a finance manager to override with a reason.", code: "credit_limit_exceeded", rule: "PTR-030" }. To go over the limit, send creditOverrideReason (at least 3 characters); it is passed to create_booking as p_booking.creditOverrideReason and accepted only from a holder of partners.credit.override — anyone else gets 403 with the same code. The override is audited (partner_credit_override) and not stored. On the seat-offer path the reason is sent with the price write; if that write is refused the draft is withdrawn and the seats given back.

The browser then builds the revenue posting and hands it to post_booking_revenue (FIN-030). The native app does not go through this route: after create_booking it calls post_booking_revenue_from_booking(p_booking_id, p_gst), which derives the same posting in the database and calls post_booking_revenue itself; a refusal there rolls the booking back with rollback_unposted_record.

The announcement (LC-007). Once the booking is posted and, with submit: true, submitted, the handler calls booking_created_notify(p_booking_id) and returns. That one database function — the same one the native app and POST /portals/agent/bookings call — keeps an AppNotification per sales approver (only once the booking is PENDING_OPS), queues the customer's booking_created and a partner's b2b_booking e-mail in CommunicationQueue for the dispatcher, and answers { approverIds, push: { title, body, url, kind, sourceRef }, approversNotified, emailsQueued, alreadyAnnounced, … }. The handler passes approverIds and push to push-send with store: false. A second call for the same booking does nothing and says alreadyAnnounced. The browser no longer invokes the mailer for a new booking, and submitBookingForApproval does not push to approvers on the create path (the announcement does). A refusal or fault in the announcement is reported (PLT-033) and never fails the create: the response is the same and the booking is in Approvals regardless.

POST /sales/bookings/import

Cite: src/lib/api.ts:3611. Bulk-import shape: { bookings: [{ bookingRef, passengers: [...], ...bookingFields }] }.

  • A passenger that arrives with a customerId is linked to that customer and nothing else is looked up. The import dialog sets it from the sheet's Customer code column (CU-000123, PTY-005); an unknown code never reaches this route — it is an error on the row in the dialog. The partner comes from the Partner code column (BP-0012) or, for older sheets, agentId / agentEmail; a code and an id or email naming different partners is refused in the dialog.
  • Otherwise resolves each passenger by passportNo. Creates a new Customer row via handleSalesCustomers('POST', ...) if none exists.
  • Updates existing customers in-place with any new data from the booking form.
  • Reports per-booking results: { ref, ok, bookingId?, error? }.

No single permission check

handleSalesBookingsImport itself does not call requirePermission, but every delegated call to handleSalesBookings('POST') does require bookings.create, and every customer insert requires customers.create. The effective permission for the import is bookings.create + customers.create.

PATCH /sales/bookings/:id

Cite: src/lib/api.ts:6299. Permission: bookings.edit.

One database call writes the edit: update_booking(p_booking_id, p_patch, p_passengers) (20261008130000). p_patch carries the booking fields the body sent (customerId, agentId, payerId, source, totalAmount, currency, paymentPolicy, bookingType, roomType, the three rates, gstEnabled, gstRate, gstAmount, creditOverrideReason); p_passengers is the traveller list (or null when the body has none) — a traveller with id is updated in place, one without is added, one left out is removed (refused for one holding an issued ticket, CXL-001). In the same transaction the database works out the GST (the typed amount, else the rate on the ground margin) and posts the voucher: an adjustment for the change when the booking is in the books, otherwise its booking voucher for the whole price (FIN-033). Any refusal — a traveller, the credit limit, the edit lock — writes nothing and comes back as the error; nothing is swallowed. flightId and pnr are not part of an edit (the flights routes own them). The route then refreshes the paid totals, moves the receivable when the payer or partner changed, and writes the audit and group history. Status transitions are refused (LC-002).

A passenger row in passengers that leaves rate out keeps the passenger's own price; rate: null clears it (PRC-004).

Edit lock (PRC-003). When the booking is not DRAFT, NEEDS_CORRECTION or REJECTED, a body that changes totalAmount, gstAmount, gstRate, gstEnabled, adultRate, childRate, infantRate, customerId, agentId, payerId, currency or source, or whose passengers add, remove or re-price a traveller, is refused before anything is written: 409, message: "This booking is with operations/finance — send it back for correction to change the price or travellers.", rule: "PRC-003", and fields, added, removed, repriced naming what would have changed. The same values sent back unchanged are dropped and the rest of the edit is saved. The database refuses the same writes ("Booking_write_guard", "BookingPassenger_write_guard"), including a date of birth that changes a traveller's category.

A partner booking whose new totalAmount (or a move onto a partner with agentId) would take the partner over its credit limit is refused before any passenger is written: 409, code: "credit_limit_exceeded" (PTR-030). creditOverrideReason goes with the same write and works as on POST /sales/bookings.

isGroupLeader is no longer a booking field (PAX-035). An old caller that still sends isGroupLeader: true makes the booking's primary (else first) live traveller the group's leader through set_group_leader (groups.edit, checked in the database); false clears the leader when they travel on this booking. The stored flag is derived and cannot be written.

DELETE /sales/bookings/:id

Cite: src/lib/api.ts:6929. Permission: bookings.delete.

Destructive — reverses journals

delete_booking soft-deletes the draft (sets deletedAt) and, in the same transaction, rejects every pending voucher of the booking (referenceType starting booking, not a reversal) and reverses every approved one (booking_reversal, through fin_post_reversal_as). If any of that fails, nothing is deleted. Refused (409) for a draft with a payment, carried money, a transferred-in traveller, a visa case, an issued ticket, or a voucher already partly reversed (LC-021). The browser posts no reversal of its own.

Per-passenger flights & hotels

Handlers: handlePassengerFlights at src/lib/api.ts:5709, handlePassengerHotels at src/lib/api.ts:5963.

Route Method Permission
/sales/bookings/:id/corrections GET bookings.view
/sales/bookings/:id/passenger-services GET bookings.view
/sales/bookings/:id/passengers/:pid/flights GET bookings.view
/sales/bookings/:id/passengers/:pid/flights POST bookings.edit
/sales/bookings/:id/passengers/:pid/flights/:assignmentId PATCH, DELETE bookings.edit
/sales/bookings/:id/passengers/:pid/hotels GET bookings.view
/sales/bookings/:id/passengers/:pid/hotels POST bookings.edit
/sales/bookings/:id/passengers/:pid/hotels/:rowId PATCH, DELETE bookings.edit

These feed the passenger-level itinerary on the booking detail page. The flight handler uses evaluateNewLeg / analyseItinerary from passengerFlightIntelligence.ts to validate route continuity; the hotel handler uses the matching stay analyser.

POST /sales/bookings/:id/passengers/:pid/transfer

Permission: booking.transfer (checked again in the database). Moves one passenger to another group, into an existing booking there or a new one (LC-030, PRC-005).

Request:

Field
targetGroupId required the group the passenger moves to
targetBookingId optional a booking in that group, of the same channel (LC-033); omitted, transfer_target_booking(p_source_booking_id, p_target_group_id) returns the booking already made for the source on that group, or makes a new one with the source's customer, payer, partner, channel, sales owner and emergency contact, which is submitted for approval
rate optional the price the passenger lands at. Omitted or blank: the price they were sold at (PRC-004)
reason required when rate differs from the sold-at price posted with the difference

The passenger, both booking totals, the vouchers, the visa case and the ticket records move in one call to transfer_passenger_to_booking(p_passenger_id, p_source_booking_id, p_target_booking_id, p_price, p_reason) (20260924130000_transfer_price_override.sql, re-created in 20261008130000_booking_flows.sql), before flight seats are moved:

  • the source total drops by the sold-at price; the target total rises by the landed price;
  • when the source booking's price is in the ledger, a booking_adjustment voucher moves the sold-at receivable from the source's party ledger to the target's, and — if the price changed — a second booking_adjustment voucher on the target booking posts the difference: Dr receivable / Cr 4000 when higher, Dr 6700 Discounts Allowed / Cr receivable when lower. Both are pending;
  • the transfer is recorded in BookingPassengerTransfer;
  • the money received for the passenger is carried to the target (BookingPaymentCarry) and both bookings' paid and balance are refreshed; when the two bookings bill different party ledgers a pending booking_adjustment voucher moves the credit (LC-032);
  • when no ACTIVE traveller is left on the source it is closed as TRANSFERRED (LC-031);
  • a traveller from a confirmed booking at the sold-at price and the same channel lands confirmed: the booking made for the transfer becomes APPROVED and is not submitted (LC-034). Otherwise a booking made for the transfer is submitted for approval.

Response: the moved passenger row plus newBookingId, newBookingNo, newBookingStatus, movedFlights, unmatchedFlights, sourceClosed, landedConfirmed, visaCasesMoved, ticketsMoved and

"price": { "soldAt": 115000, "landedAt": 100000, "difference": -15000,
           "ledgered": true, "moveEntryId": "…", "differenceEntryId": "…",
           "carried": 50000, "carryEntryId": null }

Refusals: 400 when the price changes without a reason, the passenger is cancelled, or either booking carries GST and the price changes (not decided — FIN-010); 409 when the passenger is no longer on the source booking (a second click), the accounting period is locked, the target booking is of another channel ("… keeps the channel they were sold through", LC-033), the target is TRANSFERRED ("BK-… was transferred to BK-…; it is read-only."), CANCELLED or REJECTED, or a DRAFT not made for this transfer, the passenger or the source booking has a cancellation request waiting, the customer already has a live booking on the target group (LC-035), or the passenger is the last on the source while a payment on it waits to be verified or a refund waits for approval. A refusal moves nothing, and a booking made for the transfer is removed.

GET /sales/bookings rows carry transferredAt, transferredToBookingId, transferredToBookingNos, transferredOutCount, transferredFromBookingId, transferredFromBookingNo, carriedIn and carriedOut; status may be TRANSFERRED. GET /sales/bookings/:id also returns carries ([{id, direction: in|out, amount, otherBookingId, otherBookingNo, passengerName, createdAt, entryId}]) and transfers ([{id, direction, passengerId, passengerName, otherBookingId, otherBookingNo, fromBookingId, fromBookingNo, toBookingId, toBookingNo, fromGroupName, toGroupName, soldAt, landedAt, difference, reason, createdAt, byId, byName}]) from booking_screen, which reads them from booking_transfer_trail(p_booking_id) — the same function the phone booking screen calls.


Families on a departure (PAX-036, PAX-021)

A family is its own record on one departure, not a booking. Every write is one database function that checks the caller itself: staff need bookings.edit; a partner may act on the travellers of their own bookings only. For staff the route refuses without bookings.edit before calling the database; for a partner the database decides. Errors are the database's own words (403 not allowed, 404 not found, 409 a rule refused it, 400 bad input).

Route Method Permission Does
/families/relationships GET any signed-in user [{code, label, sortOrder, active}] — the relationship list, switched-off ones included
/families/relationships POST {code, label, sortOrder?, active?} admin.config.edit family_relationship_save — add or change one. Never deleted; head cannot be switched off
/families/booking/:bookingId GET bookings.view, or the partner who owns the booking booking_families — {bookingId, bookingNo, groupId, canEdit, needsDecision, travellers[], families[], guardianCandidates[], relationships[]}. needsDecision is true when the booking has two or more live travellers and one is not recorded. A partner does not see the names of travellers on other partners' or the office's bookings
/families/booking/:bookingId/prefill GET as above family_prefill_from_booking — a suggested family for the booking: head (the primary adult), label from the head's surname, each traveller's relationship read from the old free text where it is certain (null where it is not — "family", "friend", blank). Saves nothing
/families/readiness/:groupId GET groups.view, bookings.view, or the group's tour leader family_readiness — {travellers, inFamily, notFamily, notRecorded, soloNotRecorded, minors, minorsWithoutGuardian, families, familiesWithoutHead, infantsApartFromGuardian}
/families POST {groupId, family: {id?, label, headPassengerId, members: [{passengerId, relationship}], reason?}} bookings.edit, or a partner for their own travellers family_save — create (at least two travellers) or change a family. The head must be a member and is recorded as head; every other member needs a relationship from the active list; a traveller already in another family is refused; members left out are removed. Saving the same family again changes nothing (unchanged: true). Returns the family with its members
/families/:id/remove-member POST {passengerId, markNotFamily?, reason?} as above family_remove_member. Removing the head leaves the family needing a new head; removing the last member dissolves it
/families/:id/dissolve POST {reason} as above family_dissolve — a reason is required; the family row is kept; members go back to not recorded
/families/status POST {passengerIds[], status: not_family \| not_recorded, reason?} as above travellers_set_family_status — returns {done[], skipped[]}; a traveller in a family is skipped. "Family" is never a status: it is membership
/families/guardian POST {passengerId, guardianPassengerId \| null, reason?} as above traveller_set_guardian — a child or infant names an adult live on the same departure (any booking); null clears it

The booking and group screens carry each traveller's familyStatus (family, not_family, not_recorded), family ({id, label, headPassengerId, relationship, relationshipLabel} or null), guardianPassengerId and guardianName on every passenger (prf_booking_passengers). The rooming list and flight manifest exports carry family and relationship per traveller.

Phone app — bookings (database functions)

Issue #559 step 4. The phone's booking screen and Approvals call the database functions the routes above call, with the same arguments: update_booking(p_booking_id, p_patch, p_passengers => NULL) with only roomType and, on a direct booking, paymentPolicy (PRC-003); request_booking_cancellation and request_passenger_cancellation (LC-020); refund_preview(NULL, p_booking_id) and request_booking_refund with no account (the account the money came into); approve_refund (Finance API). One function is new, 20261009110000_app_bookings.sql, because the website's reject routes write the row from the browser:

Function Does Refuses
app_reject_cancellation(p_booking_id, p_passenger_id, p_reason) Rejects the pending request of the whole booking (p_passenger_id NULL) or of one traveller on it: cancellationStatus rejected, cancellationRejectedBy = the caller, cancellationRejectedAt, cancellationRejectionReason. Audit cancellation_rejected / passenger_cancellation_rejected (source db_function, metadata.from app); an AppNotification to the requester. Answers {bookingId, passengerId, status, alreadyRejected}; a request already rejected answers alreadyRejected: true and writes nothing nobody signed in or no bookings.cancel.approve (42501); a reason under three characters (22023); an unknown booking, or a traveller not on it (P0002); no pending request, or a traveller already cancelled (23514)

The anonymous key may not call it. Approving a cancellation has no phone function: the website's approval releases the services and posts the credit from the browser first.

Operations approval

Handler: handleOperationsBookings in src/lib/api.ts. Each route is a thin wrapper over a database function that re-checks the permission, takes the approver from the session and enforces maker-checker (LC-002, LC-010).

Route Method Permission Function
/operations/bookings/:id/ops-approve POST approvals.approve ops_decide_booking('approve')
/operations/bookings/:id/ops-send-back POST approvals.approve ops_decide_booking('send_back') — reason required
/operations/bookings/:id/resubmit POST bookings.edit resubmit_booking — reason required. Returns { ok, status }: the status it went back to (PENDING_OPS, or PENDING_FINANCE when finance sent it back or rejected it)
/operations/bookings/:id/corrections/assign POST approvals.approve assign_booking_correction(p_booking_id, p_owner_id) — body { ownerId } (null clears it). Only a NEEDS_CORRECTION or REJECTED booking; the owner must be an active user. Returns { success, bookingId, correctionOwnerId, changed }; the same owner twice is changed: false
/portals/agent/bookings/:id/resubmit POST the owning partner (no permission) partner_resubmit_booking — body { note } (3 characters or more). The partner's own NEEDS_CORRECTION booking goes back to the team that sent it back (PTR-023); 403 for another agency's booking, 409 for a rejected one

Maker-checker. The creator is refused, for approve and for send-back: "Maker-checker: you cannot approve or send back a booking you created (LC-010)."

Readiness. Approving re-runs the full readiness check (name, date of birth and passport per passenger; at least one adult with a child or infant; at most one infant per adult) and re-checks group capacity under a row lock. A booking that is not ready is refused by name.

Idempotency. A second call on an already-decided booking returns alreadyDecided without acting twice.

See Approvals and Bookings.


Quotations (sales wizard)

Handler: handleSalesQuotations at src/lib/api.ts:9677.

Route Method Permission Purpose
/sales/quotations GET (read-only, agent-scoped) List quotations; partners see their own
/sales/quotations POST quotations.create Create quotation with line items, mint quotationNo
/sales/quotations/:id GET (read-only) Full quotation with line items, customer, agent, group
/sales/quotations/:id PATCH quotations.create Update status/notes/totals/line-items. status: rejected or expired needs reason (SAL-001)
/sales/quotations/:id/send POST (no explicit gate — read-only precondition) Mark status=sent and email customer

Losing a quotation needs a reason (SAL-001)

PATCH /sales/quotations/:id with status rejected or expired is refused with 400 unless the body carries a reason of 3 characters or more. It is stored as rejectionReason with rejectedAt and rejectedBy, and any other status clears those three. Reads (GET one, GET the list) return them plus rejectedByName, the resolved user name. Every status change writes a status_changed audit entry.

Quotation PATCH requires quotations.create

Cite: src/lib/api.ts:9908. The PATCH reuses the create permission rather than a separate quotations.edit. Intentional — editing an unsent quotation is effectively the same authority as creating one.


Travel groups

Handler: handleGroups at src/lib/api.ts:9946.

The Groups screens in one call (PRF-010)

Each route is one call to a SECURITY INVOKER database function, so it reads as the caller through row-level security. A section the caller may not see is null. Each section has the shape of the route named beside it. See Performance.

Route Method Permission Returns
/groups/screen?scope=active\|archived\|all GET row security (as GET /groups) {groups, flightsSummary, hotelGroupIds, groundGroupIds, quotaBlocks, fit} — groups as GET /groups; flightsSummary as GET /groups/flights-summary; quotaBlocks [{id, blockCode, flightNo, pnr, departureDate, departAt, origin, destination, totalSeats, airline}] and fit [{id, fitCode, pnr, departureDate, departAt, origin, destination, totalSeats}], unarchived. A customer or partner gets the public list and empty sections
/groups/:id/screen GET row security; group and documents need groups.view, visaCases needs visa.view {group, bookings, flights, hotelAssignments, foodAssignments, groundAssignments, visaCases, travellerServices, leftGroup, miscExpenses, documents, hotelInventory, incidentBanner, travellerStates, checkins, counts} — as GET /groups/:id (without cancellationPolicy), /groups/:id/bookings, /groups/:id/flights, /hotels/assignments?groupId=, /food/assignments?groupId=, /ground-transfers/assignments?groupId=, /groups/:id/misc-expenses, /groups/:id/documents, /hotels, the incident banner, traveller states and /field/groups/:id/checkins?limit=5. visaCases is [{id, bookingId, bookingPassengerId, status}] for this group's bookings only. group.groupLeaderPassengerId is the traveller who leads the group (PAX-035). travellerServices is one {passengerId, stays[], meals[], ground[], flights[], tickets[]} per traveller of the group's bookings — what each person is on. leftGroup is [{passengerId, name, fromBookingNo, toGroupId, toGroupName, toGroupCode, toBookingNo, reason, at}], the travellers transferred out (cancelled ones are in the passengers with cancelledAt). Bookings' customer carries customerCode and agent carries partnerCode. counts is {bookings, passengers, leftGroup, flights, hotels, meals, transfers, documents, visaCases, expenses}; on the fallback path travellerServices and leftGroup are null
/groups/:id/financials GET row security; the P&L needs finance.view or groups.view {expenses, expensesError, financialSummary, financialSummaryError, miscExpenses} — expenses as GET /groups/:id/expenses, financialSummary as /groups/:id/financial-summary, computed by the same code. A half the caller may not see is null, with the reason in its …Error
/groups/:id/invoices-screen GET group_invoices.view (else 403) {invoices, bookings, partners, customers} — invoices as GET /group-invoices?groupId= (with payerName), bookings as /groups/:id/bookings, and [{id, name, company}] / [{id, firstName, lastName}] for only the partners and customers the tab names

Group CRUD

Route Method Permission Purpose
/groups GET (read-only) List groups (supports ?scope=active|archived|all)
/groups POST groups.create Create group; groupCode and name auto-minted by Postgres trigger. Accepts operatorGroupCode — the code the business itself uses, unique per season (INV-005)
/groups/status GET (read-only) Group status summary
/groups/flights-summary GET (read-only) [{id, groupId, airlineCode, airlineName}] — the airline on each group flight. Until 26 Sep 2026 GET /groups/:id read flights-summary as a group id and this answered 404; it is now matched first
/groups/:id PATCH groups.edit Update group (the generated groupCode/name/type are immutable post-create; dates, capacity, operatorGroupCode, metadata, itinerary, serviceChargePerPax / serviceChargeLabel (FIN-039) and the status override are editable). Partial: only the fields sent change. statusOverride is one of planning, open, full, departed, completed, cancelled or null (automatic); setting or changing it needs statusOverrideReason (INV-006)
/groups/:id DELETE groups.delete Soft-delete (isActive=false)
/groups/:id/bookings GET (read-only) Bookings in the group with live payment recomputation
/groups/:id/expenses GET (read-only) Group expense/P&L report (flights, hotels, meals, ground, commissions)
/groups/:id/available-hotels GET bookings.view Hotels available to assign to this group
/groups/:id/available-flights GET bookings.view Available airline blocks + FIT rows
/groups/:id/leader POST {passengerId} groups.edit (and in the database) Name the traveller who leads the group, or clear it with passengerId: null — set_group_leader(p_group_id, p_passenger_id). Refused unless the traveller is live on a booking of this group; one per group; audited. Returns {groupId, passengerId, name, bookingId, bookingNo, previousPassengerId, alreadyLeader} (PAX-035). Not the tour leader (/groups/:id/tour-leader)
/groups/:id/travellers/place POST {kind: hotel\|meal\|ground, assignmentId, passengerIds[], roomShareGroup?, mealPlan?} bookings.edit (and in the database) Put the selected travellers into one of this group's hotel allocations, meal plans or transfers in one call — place_travellers(…). Per traveller: placed, skipped (already there) or refused with the reason (not on this group, did not buy it — PAX-034, passport under 185 days past departure, no room or portion left, booking not confirmed — LC-004). Returns {kind, assignmentId, placed[], skipped[], refused[], placedCount}
/groups/:id/programme GET groups.view (and trv_can_see_group in the database) The departure's programme, trv_trip_programme(p_group_id): {group, itinerary, activities, notices} (see field). Read by the Itinerary tab when it opens, and by Copy, Export and Print (INV-008). 403 when the database returns nothing
/groups/:id/activities POST {id, onDate?, startsAt?, endsAt?, kind?, title, place?, notes?, status?, sortOrder?, lat?, lng?} groups.edit (and trv_can_write_group in the database) Add or change one activity — trv_upsert_activity. A key left out is kept on an edit; a key sent empty clears it. The planner sends its own id for a new activity, so a repeated click saves the same row. lat/lng are passed only as numbers. Returns the row
/groups/:id/activities/reorder POST {ids[]} groups.edit (and in the database) The order of a day's activities, trv_reorder_activities; returns how many rows moved
/groups/:id/activities/:activityId DELETE groups.edit (and in the database) Delete an activity, trv_delete_activity. {ok: true}
/groups/:id/ledger GET groups.view + finance.view This departure's own vouchers (FIN-035). Every entry tagged to the group, with approvedDebit and pendingDebit. Accepts the same query parameters as /finance/journals (status, fromDate, toDate, accountId, limit, offset, q)

Programme templates

A departure's programme saved for reuse (INV-009). The routes are loaded on demand (src/lib/lazyRoutes/programmeTemplates.ts). Each calls one database function, which checks the permission again and writes the audit row.

Route Method Permission Purpose
/programme-templates GET groups.view (and in the database) Every template with its items, programme_templates(): [{id, name, tripType, sourceGroupId, createdBy, createdByName, createdAt, updatedAt, items[{id, templateId, dayOffset, kind, startsAt, endsAt, title, place, notes, sortOrder}]}], by name. dayOffset 0 is Day 1
/programme-templates POST {groupId, name, tripType?} groups.edit (and in the database) Save the departure's programme as a template, programme_template_save. Cancelled activities and any before the departure are left out. Returns the template plus {saved, days, leftOutCancelled, leftOutBeforeDeparture}. Refused: no departure date, no activities, a name in use (any case), an unknown trip type
/programme-templates/:id/apply POST {groupId} groups.edit (and in the database) Add the template's activities to the departure, programme_template_apply: each on departure + dayOffset, at the end of its day, planned; one past the return date on the return date, notes "(outside the trip — check)". Returns {templateId, templateName, groupId, added, alreadyThere, outsideTrip, lastDay}. A repeat adds nothing (sourceKey tpl:<groupId>:<itemId>)
/programme-templates/:id PATCH {name, tripType?} groups.edit (and in the database) Rename, or change the trip type, programme_template_update. Returns the template
/programme-templates/:id DELETE groups.edit (and in the database) Delete the template and its items, programme_template_delete. Departures it was applied to keep their activities. {id, deleted: true}

Group pricing

Route Method Permission Purpose
/groups/:id/pricing GET groups.view Current rate sheet
/groups/:id/pricing PATCH group_pricing.edit Save rate sheet
/groups/:id/pricing/refresh-from-inventory POST group_pricing.edit Preview defaults from linked inventory (does not save)
/groups/:id/pricing/copy-sources?q= GET group_pricing.edit (+ groups.view in the function) The other departures with a rate sheet, by group code, operator code, name or departure date (DD/MM/YYYY or YYYY-MM-DD), newest first, at most 30. Each row: id, groupCode, operatorGroupCode, name, departureDate, summary (currency, adult, child, infant before tax, customLines, taxLines) and pricing. Through rate_sheet_sources
/groups/:id/pricing/copy POST group_pricing.edit (+ groups.view in the function) Copy another departure's rate sheet onto this one and save it (PRC-006). Body { sourceGroupId, parts?, force?, reason? }. parts omitted or empty = the whole sheet; otherwise any of lineItems, lineItems.flight / .hotel / .visa / .groundServices, customLineItems, taxLines. Returns the new sheet. Through copy_group_pricing

Copy refusals (copy_group_pricing, all 409 unless noted): the same departure (400); a target that is archived, cancelled, departed or completed, or whose departure date has come; a source with no rate sheet (nothing priced and no tax line); a different currency on the two sheets — nothing is converted; live bookings on the target without force: true (the web and phone confirmations send it). Existing bookings keep their prices whatever is copied: the function never touches Booking or BookingPassenger. A copied tax line keeps only the lines the new sheet has in appliesTo. Every copy writes one AuditLog row (rate_sheet_copied: source, target, parts, reason, live bookings kept, before/after summary). | /groups/:id/pricing/refresh-from-inventory | POST | group_pricing.edit | The per-traveller cost of the linked inventory, in INR, shaped as a rate sheet (does not save). The Pricing tab shows it beside the prices; it never replaces them unless the user chooses Use cost as price (PRC-006) |

Cite: src/lib/api.ts:10473-10657.

Cite: src/lib/api.ts:10827.

Route Method Permission Purpose
/groups/:id/flights GET (read-only) All flights linked to the group
/groups/:id/flights POST groups.edit Link an AirlineQuotaBlock or FITInventory (exclusivity enforced — one block per group). Refused when the block has fewer seats left than the departure needs, saying by how many (INV-012)
/groups/:id/flights/:flightId PATCH groups.edit Update PNR / notes
/groups/:id/flights/:flightId DELETE groups.edit Unlink

Group misc expenses

Cite: src/lib/api.ts:10998.

Route Method Permission Purpose
/groups/:id/misc-expenses GET (read-only) List
/groups/:id/misc-expenses POST groups.edit Record expense. Posts through create_group_misc_expense on groups.edit — no finance right needed. Credits sourceAccountId when given, Sundry Creditors when not. Pass supplierTransactionId when the cost is already recorded as that supplier's bill: the expense then posts nothing and the bill's voucher is tagged to this departure, so the cost is in the books once (FIN-030)
/groups/:id/misc-expenses/:expenseId PATCH, DELETE groups.edit Edit or delete (reverses journal on delete)

Group rate sync to bookings

The group-pricing rate sheet drives both:

  1. GroupInvoice totals (line-items × pax snapshot) — see below.
  2. Booking defaults when bookings are created under the group.

Refreshing pricing from inventory (POST /groups/:id/pricing/refresh-from-inventory) reads linked flights/hotels/meals/transfers, converts non-INR amounts via per-contract exchangeRate, flattens per-assignment costs into per-pax rates, and returns the computed rate sheet without saving. The Pricing tab shows these costs and the margin beside the selling prices (PRC-007); the sheet changes only through Use cost as price and is saved only by PATCH /groups/:id/pricing.


Group invoices

Handler: handleGroupInvoices at src/lib/api.ts:9095.

Route Method Permission Purpose
/group-invoices GET group_invoices.view List (supports ?groupId=, ?status=, ?openOnly=1)
/group-invoices POST group_invoices.create Create DRAFT invoice for a payer
/group-invoices/:id GET group_invoices.view One invoice + payer name + payerCode (the partner's BP- or the customer's CU- code, PTY-006); the name in voucher narrations stays the plain name
/group-invoices/:id PATCH group_invoices.edit Edit DRAFT only — recomputes taxes and totals server-side
/group-invoices/:id DELETE group_invoices.cancel Delete DRAFT only
/group-invoices/:id/issue POST group_invoices.issue DRAFT → ISSUED; mints invoiceNumber and locks the invoice. Posts nothing
/group-invoices/:id/post POST group_invoices.issue Posts DR receivable / CR revenue + tax for an ISSUED or PAID invoice. Posts nothing, and says why, when the group's bookings already carry revenue journals (skipped: 'bookings_already_posted') or the invoice was posted before (skipped: 'already_posted'); skippedReason is the sentence (FIN-036). An internal invoice is refused. The voucher is stored pending and approved in approve_journal_entries (finance.journals.approve, never by its maker — FIN-032)
/group-invoices/:id/cancel POST group_invoices.cancel ISSUED/PAID → CANCELLED. A posted invoice gets a reversing credit note through post_journal_reversal — pending unless the invoice's voucher is approved and the caller may approve it (FIN-031); one never posted gets nothing posted
/group-invoices/:id/supplementary POST group_invoices.create Create a child DRAFT for pax delta (parent must be ISSUED/PAID)

State machine

DRAFT → ISSUED → PAID or CANCELLED.

  • PATCH is rejected (400) on anything except DRAFT.
  • DELETE is rejected on anything except DRAFT ("cancel instead").
  • Issue is idempotent — an already-ISSUED invoice returns the current row.
  • Post is idempotent — an invoice with a journalEntryId returns the current row with skipped: 'already_posted'.
  • Cancel is idempotent — an already-CANCELLED invoice returns the current row.

Cancel posts a credit note, doesn't just flip status

When the invoice was posted (journalEntryId set), POST /group-invoices/:id/cancel fetches the original JournalLine rows and posts an inverted journal through post_journal_reversal (scaled down by any passenger credit notes already posted), so the GL balances are restored. The reversal is not auto-approved: it follows the original voucher, and posts approved only when that voucher is approved and the caller holds finance.journals.approve and did not make it; otherwise it waits for a finance approver. An invoice that was issued but never posted is cancelled with nothing posted. A DRAFT is deleted, not cancelled.

Supplementary invoices

Child of an ISSUED/PAID parent, used when additional passengers join the group after the parent is issued. Priced using the parent's current rate sheet times the pax delta. Shares the PAID status with the parent on full payment. Cite: src/lib/api.ts:9371.


Group status

Handler: handleGroupStatus at src/lib/api.ts:5325. GET /groups/status — summary counts for the Groups dashboard (active, departing soon, completed).


Portals (agent + customer)

Partner and customer portal routes are JWT-authenticated but un-gated at the permission layer. They scope every read/write to the authenticated party's own rows via resolveAgentForCurrentUser() / customer email lookup.

Agent portal

Handlers: handleAgentPortal at src/lib/api.ts:9455, handleAgentInvoices at :8821, handleAgentPortalLedger at :8549, handlePartnerSeatSales at :8706.

Route Method Purpose
/portals/agent GET Partner dashboard snapshot. The partner's customers, quotations and bookings are read to the end, a page of 1,000 at a time, so totalBookings and totalRevenue count every booking (PLT-050). Each booking carries agreedDue and awaitingApproval (computed fields booking_agreed_due / booking_awaiting_approval; FIN-033)
/portals/agent/profile GET, PATCH Self-profile (partners can only update name + contactPerson)
/portals/agent/invoices GET Agent's invoices
/portals/agent/invoices/:id GET, PATCH One invoice
/portals/agent/ledger GET Agent ledger with running balance
/portals/agent/seat-sales GET, POST Partner seat sales (B2B flight offer sales)
/portals/agent/seat-sales/:id GET, PATCH One sale
/portals/agent/requests GET, POST Partner's own service requests
/portals/agent/payments GET, POST The Payments page in one round trip; POST = I have paid (partner_submit_payment) — see Partner payments
/portals/agent/payments/online-order POST Pay online: a Razorpay order on one booking (razorpay-order)
/portals/agent/payments/online-order/:id GET Whether razorpay-webhook has recorded that order's payment

The native app calls the partner functions directly (no api.ts route), each taking the partner from the session (PTR-011):

Function Purpose
partner_my_account() The agency, its status, portal_agent_outstanding against the credit limit, the documents on file and the required / missing docTypes, and leadsGroups (PTR-004). null when the login has no agency.
partner_add_document(p_doc jsonb) Records one registration document (docType, fileName, mimeType, driveFileId / driveFolderId / links or storageKey) under the caller's agency; refuses a link, an unknown type, a closed account, more than 20 (PTR-080). Called by upload-partner-doc as the partner.
partner_submit_payment(p_amount, p_method, p_reference?, p_booking_id?, p_group_invoice_id?) A pending Payment on exactly one of the agency's own bookings or issued group invoices; capped at the agreed due (price + GST − receipts − credit, FIN-033) after claims already waiting; reference required unless cash; the same claim within two minutes returns the first (PTR-081, FIN-032).
booking_created_notify(p_booking_id) The announcement of a booking the partner just made (or a staff member's own booking): an AppNotification per sales approver once the booking is PENDING_OPS, the booking_created and b2b_booking e-mails queued for the dispatcher, the approver ids and push text returned for push-send with store:false. Once per booking; the creator or the owning partner only (LC-007). Also called by POST /sales/bookings and POST /portals/agent/bookings.
edge function partner-signup Public. Creates the login unconfirmed, the User row, the AGENT role and a pending Agent row, and sends the confirmation email; action: "resend" sends it again. App key or captcha; throttled (PTR-082).
edge function upload-partner-doc A signed-in partner. Body docType, fileName, mimeType, fileData (base64, under 6 MB). Files the document on the company drive under Partners / "<agency> (<id>)" (not public) and calls partner_add_document with the caller's session.

Customer portal

Handlers: handleCustomerPortal at src/lib/api.ts:8321, handleCustomerQuotations at :8082, handleCustomerProfile at :8277, handleCustomerPortalRequests at :8154, handleCustomerPortalCreateRequest at :8202.

Route Method Purpose
/portals/customer GET Customer dashboard. Each booking carries agreedDue and awaitingApproval (FIN-033)
/portals/customer/profile GET, PATCH Self-profile
/portals/customer/quotations GET Customer's quotations
/portals/customer/quotations/:id/accept POST Accept own quotation
/portals/customer/quotations/:id/reject POST Reject own quotation
/portals/customer/requests GET, POST Customer's own requests
/portals/customer/requests/new POST Create a new request

Service requests

Handler: handleRequests at src/lib/api.ts:20115 (staff-facing) + inline at src/lib/api.ts:27920 for creation.

Route Method Permission Purpose
/requests GET (read-only) List staff-facing service requests
/requests POST requests.create Staff creates a request (customers use portal endpoint instead)
/requests/:id PATCH requests.edit Update status, priority, notes
/requests/:id/notes POST requests.edit Append an internal note

Leads

Handler: handleLeads at src/lib/api.ts:20023, handleLeadById at :20076, handleLeadConversion at :19897.

Route Method Permission Purpose
/leads or /sales/leads GET, POST leads.edit (POST) List / create lead
/leads/:id PATCH, DELETE leads.edit Update / delete. status: lost or closed needs reason (SAL-001). There is no GET for one lead — the screen reads it from the list
/leads/:id/convert POST leads.edit Convert lead → customer (+ optional booking). type: 'booking' books the total less discount (the quotation's finalTotal) and every traveller in Lead.passengers, the lead's customer first (SAL-002)

Losing a lead needs a reason (SAL-001)

Allowed statuses are new, contacted, qualified, converted, closed and lost. A PATCH to lost or closed is refused with 400 unless the body carries a reason of 3 characters or more; it is stored as lostReason with lostAt and lostBy, and any other status clears those three. GET /leads and the PATCH response return them plus lostByName, the resolved user name. Every status change writes a status_changed audit entry.

GET /leads?id=<leadId> returns just that lead (the work inbox links to it; the list is paged, so the lead may not be on the first page). Saving a lead also raises or closes its work item — see Work inbox.

GET /leads returns passengers — the names a visitor typed on the public form — and attachments, each carrying fileName, mimeType, fileSize, label and storageKey — drive:<id>, the file on the company Shared Drive. It never returns a URL for a file: the leads screen opens one through drive-file (kind file, leads.view), which hands out a ten-minute link (AUD-021, ACC-074).

Public submissions arrive through the `lead-intake` edge function, not this route: it
validates and length-caps every field, refuses more than 20 passengers or 10 files, and
stores only a storage key for an uploaded document ([AUD-021](../rules/09-audit-and-data-protection.md)).

Cancellation policies

Rules: PRC-020, PRC-023.

Route Method Permission Purpose
/cancellation-policies GET groups.view or admin.view Every policy with its slabs (minDaysBefore, maxDaysBefore, chargePercent, flatCharge), its kept items (components: label, source rate_sheet|fixed, lineKey, fixedAmount, keptWhen always|visa_applied|ticket_issued) and percentBase (full_price|after_kept_items)
/cancellation-policies POST cancellation_policies.manage Create; body is the same shape. Saved by save_cancellation_policy in one transaction
/cancellation-policies/:id PATCH cancellation_policies.manage Fields not sent keep their value; slabs and components, when sent, replace the old ones whole
/cancellation-policies/:id DELETE cancellation_policies.manage 409 while any group uses the policy

Validation happens in the database: at least one band, no overlapping bands, percentages 0–100, no negative amounts, a rate-sheet item names a line, a fixed item has an amount, and the default policy must be active. Errors come back as 400 with the reason.

cancellation_quote(p_group_id, p_items, p_as_of) — RPC behind every cancel preview and approval. p_items is [{ passengerId, price }]; it returns the policy, daysBefore, the matched band, and per traveller keptItems, keptTotal, flatCharge, percentCharge, chargeAmount, refundAmount and effectivePercent. Needs bookings.cancel, bookings.cancel.approve or cancellation_policies.manage. Refusals are 409.

The cancel-preview routes return the same breakdown: matchedSlab.flatCharge, and on each passenger keptItems, flatCharge and percentCharge. The top-level and per-passenger chargePercent is now the charge as a percentage of the price, since a flat charge or a kept item has no single percentage.