03 · Pricing, cancellation and refunds
Pricing
PRC-001 · The group rate sheet is the price list
Status: PROPOSED · Owner: Sales Package prices come from the group's rate sheet (per service line and passenger category, PAX-030). Sales cannot type a different total; they apply a discount (PRC-002).
PRC-002 · Discounts need authority
Status: OPEN · Owner: Management Any price below the rate sheet is a discount with a reason. Decide the discount limit per role (e.g. sales executive up to X%, sales manager up to Y%, above that GM/Director). Approval limits are enforced by the approval engine (ACC-030).
PRC-003 · Price is locked at confirmation
Status: DECIDED 2026-10-01 (owner) · Owner: Finance Once a booking leaves sales — it is PENDING_OPS, PENDING_FINANCE, ON_HOLD, APPROVED, PARTIALLY_CANCELLED or later — its price and its travellers change only by sending it back for correction (NEEDS_CORRECTION), or through a cancellation or a transfer. While it is DRAFT, NEEDS_CORRECTION or REJECTED (put right and sent back to finance, LC-005) sales may change both. Contact details, passport details, notes and room choices stay editable at any status. A confirmed booking that must change its price does so through a documented adjustment (supplementary invoice or credit note, FIN-031), never by editing the total.
"Price" is the booking total, GST, the adult/child/infant rates, the currency, the customer, the payer, the business partner and the source. "Travellers" is adding or removing one, a traveller's own price (PRC-004), and a date of birth that changes their category (adult, child, infant).
An edit is one database transaction (update_booking, 20261008130000): the booking's fields, its travellers, the GST decision and the voucher are written together, and a traveller the database refuses undoes the whole edit — the total is never left raised without its travellers or its voucher. update_booking runs as its owner, so it applies this lock itself, in the same words.
Enforced by: 20261007100000_booking_db_guards.sql — the triggers "Booking_write_guard" and "BookingPassenger_write_guard" refuse a browser write of any of those once the booking has left sales ("This booking is with operations/finance — send it back for correction to change the price or travellers"); database functions (transfer, cancellation, partner functions) are not affected. finance_decide_booking re-runs the readiness and capacity checks before approving, as ops_decide_booking does. PATCH /sales/bookings/:id refuses the same changes up front with a 409, and the edit wizard shows the fields read-only with that hint. Tests: supabase/tests/booking_db_guards.sql, src/lib/api.bookingDbGuards.test.ts, src/lib/bookingEditLock.test.ts.
Not built: a supplementary invoice or credit note for a price change on a confirmed booking; the service flags (needs ticket, visa, hotel, meals, ground) are not locked.
PRC-004 · A passenger may carry their own price
Status: PROPOSED 2026-09-22 · Owner: Sales / Finance A passenger may be sold at a price of their own — on the 12 Aug 2026 departure every pilgrim's price was negotiated separately (₹65,000 to ₹1,52,000). When a passenger carries one, it is their price: it is never also counted at the booking's category rate. When they do not, the booking's rate for their category applies.
The price a passenger was sold at is, in order: their own price; else the booking's rate for their category; else their share of the booking total left after the passengers who carry their own price.
Enforced by: BookingPassenger.rate (NULL = the category rate applies). create_booking prices each passenger as own price else category rate; booking_passenger_sold_price() (20260924130000_transfer_price_override.sql) is the sold-at price above; the cancellation refund uses the passenger's own price (PAX-031); a booking edit no longer wipes a passenger's own price when the screen leaves it out; a browser session may change a passenger's price only with bookings.edit (20260924140000_passenger_price_needs_bookings_edit.sql — before it, any staff session could, a visa officer included). Tests: supabase/tests/transfer_price_override.sql, supabase/tests/passenger_price_guard.sql, src/lib/api.transferPriceOverride.test.ts.
Not yet: there is no screen for setting one passenger's price on an existing booking other than the booking wizard's per-passenger price and a transfer (PRC-005 below). The deprecated inline edit dialog on the bookings list still prices by count × rate. The cancellation refund's last fallback (no own price, no category rate) still splits the total evenly over active passengers rather than using the share above.
PRC-005 · A transfer can change the price
Status: PROPOSED 2026-09-22 · Owner: Management (owner's working defaults) Moving a passenger to another departure or booking may change their price — a shorter trip or a cheaper hotel costs less, a better one more.
- Without a new price the passenger keeps the price they were sold at (PRC-004). The old booking gives up that price and the new booking bills it, and the receivable moves by the same amount.
- With a new price the passenger lands on the new booking at that price and carries it as their own price. The old booking still gives up the price they were sold at, and the receivable voucher moves the sold-at amount.
- The difference posts as its own voucher, on the new booking and so tagged to the new departure (FIN-035), narrated with the reason: a higher price is Dr the customer's receivable / Cr
4000package revenue; a lower price is Dr6700Discounts Allowed / Cr the customer's receivable. Both vouchers landpendingfor a second person (FIN-032). - A reason is required whenever the new price differs from the sold-at price.
- The service charge does not move (FIN-039). It is a flat charge per traveller, earned when the booking was taken, so it stays on the old booking and a booking made for the transfer does not charge it again.
- Discount authority is not enforced. Anyone who may transfer a passenger may change the price by any amount, with a reason. The limit per role is PRC-002, which is OPEN; the working default in
ApprovalLimit("Discount off the rate sheet") is advisory. On a rate-sheet departure a transfer price is not checked againstgroup_pricing.edit(PAX-030).
A price change on a transfer is approved by someone other than the person who transferred (LC-034): the booking made for the traveller goes for approval, and finance approval refuses the transferring person; the difference voucher waits for a second person (FIN-032).
Enforced by: transfer_passenger_to_booking() (20260924130000_transfer_price_override.sql, re-created in 20261008130000_booking_flows.sql) — moves the passenger, both booking totals and both vouchers in one transaction, gated on booking.transfer, posting as the system. It refuses a price change without a reason, a price change on a booking that carries GST, and a cancelled passenger. Each transfer is recorded in BookingPassengerTransfer with both prices and the reason. 6700 Discounts Allowed is named by FinanceConfig.discountAllowedAccountCode (6400 in this chart is Rent & Utilities). Tests: supabase/tests/transfer_price_override.sql, src/lib/api.transferPriceOverride.test.ts.
Needs a decision:
- GST. How GST follows a price change on a transfer is not decided (FIN-010 is OPEN), so a price change on a booking that carries GST is refused. A plain transfer still goes through, but it does not move GST either: the old booking keeps its whole GST amount and the new booking has none.
- Agent commission. Commission was posted on the old booking at the sold-at price. A transfer — with or without a price change — does not adjust it or move it.
- Revenue by departure. The sale's revenue voucher stays on the old departure; only the receivable, and any difference, follow the passenger.
PRC-006 · A rate sheet can be copied from another departure
Status: DECIDED 2026-09-27 · Owner: Owner Owner, 27 Sep 2026: "include import pricing from other groups, where one can basically copy pricing from one group to another".
- Staff who may change a departure's rate sheet (
group_pricing.edit) and read departures (groups.view) may copy another departure's sheet onto it — the whole sheet, or any of the four standard lines, the custom lines as a set, the tax lines as a set. - Never onto the same departure, never into a departure that is archived, cancelled, departed or completed (or whose departure date has come), never from a departure with no rate sheet.
- Currencies are never converted. Two sheets in different currencies are not copied; a departure with no sheet takes the source's currency.
- Existing bookings keep their prices (PRC-003, PRC-004). The copied sheet prices only what is sold from then on. On a departure with live bookings the copy is confirmed first.
- A copied tax line applies only to lines the new sheet has.
- Every copy is audited: who, from which departure, which parts, the reason, and the per-category totals before and after.
Enforced by: copy_group_pricing() and rate_sheet_sources() (20261001200000_copy_a_rate_sheet.sql). Screens: Pricing tab Copy from another group (Groups §6.1), the staff app's group screen. Tests: supabase/tests/copy_a_rate_sheet.sql, src/components/groups/CopyPricingDialog.test.tsx, src/lib/api.copyGroupPricing.test.ts, apps/mobile/src/lib/rateSheet.test.ts.
PRC-007 · Inventory cost informs the rate sheet; it never replaces it
Status: PROPOSED 2026-09-27 · Owner: Sales The rate sheet is the selling price. What the linked flights, hotels, meals and transfers cost per traveller is shown next to each price, with the margin left (amount and share of the price), so the person pricing a departure sees both. Reading the cost changes no price. Prices take the cost only when someone chooses Use cost as price and confirms it — a price at cost leaves no margin, so it is never a side effect of looking. Costs are in rupees (a foreign contract at its own exchange rate); against a rate sheet in another currency no margin is shown rather than a wrong one.
Enforced by: the Pricing tab (src/components/groups/GroupPricingTab.tsx, rateSheetCost.ts) — POST /groups/:id/pricing/refresh-from-inventory returns the cost and saves nothing; the sheet is saved only by PATCH /groups/:id/pricing with a reason. Test: src/components/groups/GroupPricingTab.cost.test.tsx.
Before this, Refresh from inventory replaced every selling price on the sheet with its cost, so one click priced the departure at zero margin until someone noticed.
Payment policy
PRC-010 · Payment schedule
Status: OPEN · Owner: Finance Decide the default deposit percentage, balance due date (days before departure) and what happens when a balance is overdue (reminder, hold, cancellation). Groups may override defaults (existing per-group settings).
Cancellation
PRC-020 · Cancellation policies
Status: DECIDED (existing feature; made configurable 2026-09-19) · Owner: Management
Cancellation charges follow the policy attached to the group (or the default policy), chosen by days before departure. Each band of a policy can charge a flat amount, a percentage of the cancelled passenger's own price (PAX-031), or both, and a policy can keep items once they are spent (PRC-023). The charge is never more than the passenger's own price. Policies are configuration: staff holding cancellation_policies.manage create and change them under Operations → Cancellation policies, and a change applies to cancellations from then on.
How days before departure are counted (DECIDED 2026-10-01 · owner, issue #497): in calendar days on the Indian calendar — the departure date less today's date in IST. The time of day does not matter. At 09:00 on 10 Sep a 10 Oct departure is 30 days away, and so it is at 23:59; from midnight IST on 11 Sep it is 29. On the departure day and after it is 0. Before this the quote counted whole 24-hour periods from the current moment, so at 09:00 on 10 Sep the same departure was 29 days away and the stricter band applied a day early. Enforced by cancellation_quote() (20261004200000_money_audit_small_items.sql; test supabase/tests/money_audit_small_items.sql).
Enforced by: cancellation_quote() works out every charge and save_cancellation_policy() saves a policy in one transaction (20260923110000_flexible_cancellation_policy.sql; supabase/tests/cancellation_policy.sql). The system ships three starter policies to edit — Umrah — standard (the default where none was set), Full refund and Non-refundable. Umrah — standard reproduces the 29 Aug 2026 cancellation: ₹10,000 plus the visa price once applied for, ₹27,000 kept on ₹1,15,000; its nearer-departure bands are placeholders to review.
No policy is a question, not an answer of zero. Every cancellation path used to start at chargePercent = 0 and leave it there when nothing resolved, so a group created through POST /groups — which never attached a policy — quietly offered a 100% refund on a departure where the business had retained ₹27,000. resolveCancellationChargePercent() (src/lib/api.ts) now refuses, naming what to fix, when the group has no policy and there is no active default, when the attached policy has been deleted, when no band covers the days before departure, or when the group has no departure date. POST /groups attaches the caller's policy, or the active default, at creation, so a group says what it charges from the moment it exists. A full refund is now something a policy has to say — a 0% band — not something that happens by omission.
PRC-021 · Manual refund override
Status: DECIDED (existing feature) · Owner: Finance A manager with cancellation-approval permission may override the policy refund, between zero and the passenger's price, with a reason. Both the policy amount and the override are recorded.
PRC-022 · Supplier charges are separate
Status: DECIDED (airline spec §19–20) · Owner: Finance What the customer is charged (company cancellation fee) and what suppliers charge us (airline penalty, hotel no-show) are recorded separately. The customer charge follows our policy; supplier charges follow the supplier contract.
PRC-023 · Non-refundable components
Status: DECIDED (configurable per policy, 2026-09-19) · Owner: Management A policy lists the items it keeps once they are spent. Each item is either the passenger's own amount on a line of the group's rate sheet (e.g. the visa price for an adult) or a fixed amount, and is kept always, once the visa has been applied for, or once the ticket has been issued. The policy also says whether a band's percentage is taken on the full price or on what is left after the kept items. Which items each policy keeps is set by management on the policy, not fixed here.
A kept item is resolved in order, and the quote always says which step answered. A departure priced per pilgrim has no rate sheet to read, so a rate-sheet item is not an all-or-nothing lookup:
- the group's rate-sheet line for that traveller's category;
- else the cost this departure actually recorded for that line, shared over the travellers who needed it (a visa bill posted to the group, divided by the pilgrims carrying
needsVisa); - else the fallback amount set on the policy's kept item itself;
- else the item is skipped — nothing is kept for it and the quote carries a notice naming the item, the departure, and the two ways to fix it.
A missing rate line never stops a cancellation, and a skip is never silent. The quote returns notices: one sentence for every kept item priced from somewhere other than the rate sheet, and one for every item skipped, saying in plain words that the charge is lower than the policy intends. The cancellation screen prints them above the refund and the operator reads them before approving. The refusals that remain (PRC-020) are the ones where the policy has said nothing at all: no policy and no active default, a deleted policy, no departure date, no band covering the day count.
Enforced by (20260923130000; supabase/tests/cancellation_policy.sql, src/lib/api.cancellationPolicies.test.ts): cancellation_quote() walks the four steps and returns keptItems[].source / .basis, items[].skippedItems and a top-level notices; cancellation_recorded_line_cost() is step 2; save_cancellation_policy() stores the step-3 fallback in CancellationPolicyComponent.fixedAmount for a rate-sheet item, settable on Operations → Cancellation policies.
Before this, step 1 was the only step and anything else refused. The real 29 Aug 2026 departure prices every pilgrim separately and therefore has no rate sheet, so a policy that keeps the visa made the quote answer 409 … keeps "Visa fee" from the rate sheet, but UMR-29AUG-A-19D has no "visa" rate (PRC-023). The pilgrim could not be cancelled by anybody — not a manager, not a super admin — and ₹88,000 of refund sat stuck behind it. The only way out was to invent a rate sheet on a departure that has none, which changes what every other booking on it is billed.
What is kept is then booked for what it is. A kept item is money the company already spent, so recovering it is not income; the flat charge and the percentage are. The policy carries the cost head each kept item recovers, and the cancellation posts the two separately — see FIN-038. Nothing of a cancelled trip stays in Package Revenue.
The ₹700-a-head company service charge is not a kept item: it is charged for taking the booking, is recognised on its own income head when the booking is taken, and a cancellation does not reverse it (FIN-039).
Refunds
PRC-030 · Refund lifecycle
Status: DECIDED 2026-09-30 (owner: only what was overpaid) · Owner: Finance Refund due is created when the cancellation is approved; refund paid is recorded against a bank/cash account with reference (UTR). A customer gets back only what they paid beyond what the booking now charges: paid − (price + GST − the credit for approved passenger cancellations), less refunds already requested and not yet decided. A cancellation credit reduces the bill; it is not cash owed back, so a customer who still owes on the booking gets nothing back and the cancellation charge is kept. A payment refund also stays within that payment.
Example: one pilgrim at ₹1,00,000 has paid ₹30,000 and cancels with a ₹10,000 charge — the booking now charges ₹10,000, so ₹20,000 goes back (it used to allow ₹30,000). Two at ₹1,00,000 with ₹1,00,000 paid, one cancelled with a ₹10,000 charge — the booking charges ₹1,10,000 and the customer still owes ₹10,000, so nothing goes back (it used to allow ₹90,000).
Enforced by: fin_refundable (20261004150000_a_refund_is_what_was_overpaid.sql), which request_booking_refund, refund_payment, approve_refund (re-checked under lock, not counting the request being approved) and refund_preview all read; paid is fin_booking_receipts_total, so a booking paid through a split receipt is refundable as far as it was overpaid. approve_refund needs a second person and pays from the account the money came into unless another is chosen with a reason (20260918110000_money_integrity.sql). The refund dialog and the booking page read the same figure. Tests: supabase/tests/a_refund_is_what_was_overpaid.sql, supabase/tests/money_integrity.sql.
PRC-031 · Refund timeline
Status: OPEN · Owner: Management Decide the committed refund timeline (e.g. within N working days of approval, or after the supplier refunds us).