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
customerIdis 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 newCustomerrow viahandleSalesCustomers('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_adjustmentvoucher moves the sold-at receivable from the source's party ledger to the target's, and — if the price changed — a secondbooking_adjustmentvoucher on the target booking posts the difference: Dr receivable / Cr4000when higher, Dr6700Discounts Allowed / Cr receivable when lower. Both arepending; - 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 pendingbooking_adjustmentvoucher 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
APPROVEDand 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.
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.
Group flights (link inventory)
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:
- GroupInvoice totals (line-items × pax snapshot) — see below.
- 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
journalEntryIdreturns the current row withskipped: '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.