Receipts and refunds
Money in and money out. Both are two-person jobs, and both are enforced in the database —
src/lib/api.ts runs in the browser and cannot be the control.
Rules: FIN-032 (maker-checker on vouchers and payments), ACC-021 (nobody records and verifies the same money), PRC-030 (refund lifecycle and cap), ACC-030 (approval limits by amount).
1. A receipt, end to end
stateDiagram-v2
[*] --> pending: record_payment (cashier / accountant)
pending --> verified: verify_payment (someone else)
pending --> rejected: verify_payment 'reject' + reason
verified --> [*]
Recording — record_payment, or record_payments_batch when one cheque or transfer
covers several bookings. Both need finance.payments.record.
- The row is always inserted
pending. There is no way to record money already verified. - A receipt says when, how and against what (FIN-042),
and none of the three has a default:
receivedDateis the day the money arrived, not the day it was keyed in. It cannot be in the future and must fall in an open accounting period. That date is what dates the receipt voucher. Every screen that records a receipt has a Received on field, starting at today in IST and changeable: the Record Payment dialog, Finance → Vouchers → Receipt, the booking wizard's first payment, the payment and legacy-repair dialogs on the Bookings page, and the paid amount when a passenger is added from a departure (that one records cash only).- Not yet dated: a customer on-account receipt and a supplier refund receipt from Finance → Vouchers take no date and are dated the moment they are entered.
methodmust be stated — a receipt is not assumed to be cash.reference— the UTR, cheque number, card authorisation or UPI reference — is required for every mode but cash. Without it the receipt cannot be matched to the bank statement.
- The recorder is taken from the session, never from the request body.
- A double-click is absorbed: the same user, booking, amount, method, reference and
received date within 30 seconds returns the first row with
alreadyApplied: true. record_payments_batchtakes up to 100 allocations and records one receipt for the money that arrived, with aPaymentAllocationrow for each booking it settles, in one transaction — all of it lands or none does. A receipt over several bookings carries no singlebookingId; one against a single booking still names it.- A receipt is one party's money. Every allocation must resolve to the same receivable
ledger; a receipt spanning two payers is refused. Each booking still shows its own share
(
fin_booking_receipts_total), the register and party ledger show the single real receipt, and one verification settles the whole of it — posting one voucher against the payer's receivable (FIN-040). - A receipt is kept in the booking's currency (FIN-034). Money paid in another currency — rupees on a riyal booking, say — is converted at the rate in force on the received date (Finance → Settings → Exchange Rate Management, FIN-046), and the receipt keeps what actually arrived: the original currency, amount and rate. With no rate for that day or earlier the receipt is refused. A booking's paid and balance are always in its own currency, including a share of a receipt over several bookings that was taken in a different one. The voucher posts in rupees at the rate on or before the received date. No exchange gain or loss is recorded on a receipt; the Add Payment dialog says so. On the phone, Take payment takes the amount in the booking's currency and does not convert.
- An allocation is read by its owner: staff with
finance.view,bookings.vieworagents.ledger.view, the customer of the allocated booking, and the partner whose agency owns the booking or invoice. Row security onPaymentAllocationdecides; the screens do not filter (ACC-010).
Where a receipt is recorded. Every screen that records a receipt against a booking or
invoice shows its button to anyone with finance.payments.record — the same permission the
route and the database ask for (ACC-021). A cashier
(finance.view + finance.payments.record) can use all of them:
- Add Payment on the booking page and on the Bookings list.
- Finance → Transactions → Record Payment, and Pay on a booking under Top Outstanding.
- Finance → Vouchers → Receipt, for a customer, against a booking or invoice.
- Take payment on a booking in the phone app.
Finance → Vouchers offers only the vouchers the user may post. A receipt on account (a
customer with no booking or invoice) needs finance.edit. A supplier refund receipt, a
business partner receipt and every other voucher type (payment, contra, debit and credit
note, settlement, journal) need finance.create. A cashier sees only Receipt, for a
customer; the voucher posts once a booking or invoice is chosen.
Verifying — verify_payment(paymentId, 'verify' | 'reject', reason), needing
finance.payments.verify (rejection also accepts finance.payments.reject). On Finance →
Transactions, Reject on a pending receipt shows to anyone holding either permission, so
an accountant who verifies can also reject.
- The person who recorded it cannot verify it. The database refuses: "Maker-checker: you recorded this payment, so someone else must verify it."
- A rejection needs a reason of at least three characters.
- On verify, and in the same transaction: the receipt number is minted
(
RCP-00001, prefix and counter fromFinanceConfig), the receipt voucher is posted approved and datedreceivedDate, and the booking totals are recomputed. - The receipt voucher is in rupees (FIN-034). Rupees that arrived post as they arrived, whatever the booking's currency. Riyals or dollars post at the receipt's stored rupee rate, else the rate on or before the received date, and the voucher keeps the original amount, currency and rate — $2,000 at ₹83 posts Dr Bank ₹1,66,000 / Cr receivable ₹1,66,000. With no rate on record the verification is refused, in words, until someone adds one; the receipt stays pending. The exchange gain or loss against the rate the sale was booked at is not posted.
- Once verified, the payment has a receipt PDF the customer can keep: Receipt on the
booking's Payments tab (desktop) and on the payment in the app (staff, the partner, the
traveller), made by the edge function
issue-documentand kept on the company drive under the booking. It prints that receipt number, the date received, who paid with their party code, the amount in figures and in words, the method and reference, who verified it and the balance after it. A pending or rejected payment has none (FIN-045). - Finance → Transactions also has Receipt on a verified receipt: a short slip printed from the browser. It shows Received from — the booking's business partner with its partner code (BP-…) when the booking has one, otherwise the payer, otherwise the customer, with the customer code (CU-…) — then the booking, amount, method and date. A receipt with no booking (a group invoice payment) has no Received from line. The party comes from the bookings list the page already loads; no extra request is made.
Who is told (COMM-038, COMM-037).
The database tells people; the screen does not. The payment_email_notice trigger on
Payment queues the e-mail in CommunicationQueue and the communications-dispatcher sends
it — the same whether the payment was verified on the web, on the phone, or recorded by
razorpay-webhook for an online payment.
- Verified → a Payment received e-mail with the receipt details, once per payment. It goes to the partner (the agency's e-mail) on a partner booking; on a direct booking to the customer, or the payer when someone else pays (COMM-012). A customer who opted out of e-mail gets none. A payment with no booking (a receipt spread over several bookings) gets none.
- Rejected → when the payment was a claim a partner or customer reported themselves (I have paid), they are e-mailed Payment claim not accepted with the reason you typed. Write the reason for them: it is also shown on their Payments page for 90 days. A payment staff recorded and you reject e-mails nobody.
- An allocation of a partner's on-account receipt to a booking is not money received: it sends no e-mail and no WhatsApp.
- Finance e-mails can be switched off in Admin → Reminders → Automatic e-mails by category (COMM-039).
Who may do which half
CASHIER holds finance.view and finance.payments.record and nothing else in
finance (ACC-021, decided 2026-09-18). A cashier
cannot verify anything, including their own work. An accountant verifies receipts other
people recorded; the finance manager approves refunds and journals. This is checked by
supabase/tests/access_model.sql.
What the browser cannot do to a Payment
The Payment_guard trigger applies to every write that arrives from a signed-in browser
session:
- An insert with a non-positive amount is refused — negative rows are how refunds used to be faked.
- Status,
verifiedBy,verifiedAt,receiptNoandrejectionReasonare overwritten on insert;createdByis stamped fromauth.uid(). - On update, only
notesandreceiptUrlare editable — plusreference,sourceAccountIdandmethodwhile the row is still pending. Anything else is refused: "can only change through verify_payment / refund functions (FIN-032)". - A verified or refunded payment cannot be deleted.
2. Refunds
A refund is a request first and a payment second. It lives in PaymentRefund
(requested → approved / rejected), and the browser cannot write that table at all —
only the three functions can.
| Step | Function | Permission |
|---|---|---|
| Ask for a refund of one receipt | refund_payment |
finance.payments.refund |
| Ask for a refund against a booking's cancelled passengers | request_booking_refund |
finance.payments.refund |
| Approve or reject it | approve_refund |
finance.refunds.approve |
The cap. A refund is only what the customer paid beyond what the booking now charges
(price + GST − credits for approved cancellations), less refunds already made or waiting
for approval (PRC-030). fin_refundable() computes it per payment
and per booking, and approve_refund re-checks it at approval time under a row lock on
the booking — so two refund requests raised against the same receipt cannot both be
approved. request_booking_refund adds a second ceiling: the refund due on the cancelled
passengers of that booking, which is each passenger's own price under the policy
(PAX-031), never the adult rate.
A receipt split over several bookings (FIN-040) is refunded from one of them. The refund dialog lists the bookings the receipt paid, each with what the receipt put in and what can come back; you choose one (it is chosen for you when only one can give anything back). The cap is the least of what is left of the receipt, what it put into that booking less its earlier refunds to it, and what that booking was overpaid (PRC-030). The request carries that booking, so the approval posts against the payer's receivable and the refund comes off that booking's paid amount only. A split receipt cannot be refunded as a whole: the request is refused until a booking is named.
Maker-checker. The requester cannot approve: "you requested this refund, so someone else must approve it."
Approval limits. customer_refund is one of the two rows in ApprovalLimit that are
actually enforced: up to ₹50,000 a Finance Manager (finance.refunds.approve) signs; above
that the approver needs finance.approvals.high_value, which only CEO, GM and the super
admin hold. See Numbering and approval limits.
The second approver above ₹2,00,000 is not built
ACC-030 asks for a second approver above ₹2,00,000.
ApprovalLimit.requiresSecondApprover records that intent on the row, but no second
step exists in the code. Today the high-value tier is a single approver holding
finance.approvals.high_value.
Which account pays. The refund defaults to the account the money came into — the
payment's sourceAccountId, or the debit line of its receipt voucher. Choosing a different
account is allowed but the original and the reason are both stored on the row.
What approval posts. One approved journal entry (referenceType = 'payment_refund')
whose createdBy is the requester and approvedBy the approver, so maker ≠ checker is
visible on the voucher itself; a numbered refund voucher RFV-00001
(FIN-011 refund voucher, CGST Rules r.51); and a
ledger entry. The original receipt stays verified — flipping it to refunded would
double-count the money.
3. Routes
All of these are thin wrappers in src/lib/api.ts over the functions above. The permission
named on each is checked again in the database.
| Route | Method | Calls |
|---|---|---|
/finance/payments |
GET, POST | list (paged); record_payment |
/finance/payments/batch |
POST | record_payments_batch |
/finance/payments/:id/verify, /reject |
POST | verify_payment |
/finance/payments/:id/refund |
POST | refund_payment |
/finance/payments/:id/refund-preview |
GET | refund_preview |
/finance/refunds |
GET | PaymentRefund list |
/finance/refunds/:id/approve, /reject |
POST | approve_refund |
/sales/bookings/:id/refund |
POST | request_booking_refund |
/sales/bookings/:id/refund-preview |
GET | refund_preview |
/finance/bookings/:id/mark-paid no longer exists
It recorded a verified cash payment for the whole outstanding balance, with no receipt voucher and no second person. It was removed in Wave 1B and the route answers 404. To close a balance, record a real payment and have someone else verify it.
The payments register (GET /finance/payments) is one request: the receipts, who recorded
and verified them, and how each is allocated, from finance_screen
(PRF-010).
4. Not built yet
- GST on advances. FIN-011 is LAW: GST on a service falls due when the advance is received. Refund vouchers are issued; receipt vouchers on advances, and the GST liability they carry, are not.
- Advances as a liability. Money before travel should sit in Advances from Customers and become revenue at completion (FIN-001). The posting engine does not do this yet.
- The customer ledger from ledger entries.
Booking.paidAmountis recomputed by the database from verified payments and cannot be typed in, but the customer ledger itself is not yet derived from ledger entries (FIN-033). - Razorpay from the web customer portal. Online payment is built in the native app
(Pay online →
razorpay-order, the receipt byrazorpay-webhook— API → Online payment), on the partner web portal (Pay online on Payments,POST /portals/agent/payments/online-order— PTR-093, API → Partner payments) and on the public payment link/pay/:tokensent from the WhatsApp menu (COMM-034). The web customer portal has no button, and production has no Razorpay secrets until the owner sets them. - Online payment by staff. Deliberately not built (owner, 30 Sep 2026). Staff never start
a Razorpay payment for a customer, on the website or in the app; they record what the
customer paid with Record payment (Take payment on the phone), pending until someone else
verifies it. Only the booking's customer or payer and its business partner pay online;
razorpay-orderrefuses a staff login with "Staff don't pay online. Record the customer's payment instead." (FIN-032).
5. Where to look
| Concern | Path |
|---|---|
| Functions and guards | supabase/migrations/20260918110000_money_integrity.sql |
| Receipt date, batch receipts, supplier sub-ledgers | supabase/migrations/20260919100900_finance_receipt_date_and_batch.sql |
| Refund permission tightening | supabase/migrations/20260919100300_finance_payment_permissions.sql |
| Approval limits | supabase/migrations/20260919100700_finance_approval_limits.sql |
| Receipt and claim e-mails, the allocation marker | supabase/migrations/20261006130000_notifications_share_one_queue.sql; test supabase/tests/notifications_share_one_queue.sql |
| Route handlers | src/lib/api.ts — handleFinancePayments, handleFinanceRefunds |
| Tests | supabase/tests/money_integrity.sql, supabase/tests/finance_controls.sql, src/lib/api.moneyIntegrity.test.ts |