Skip to content

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:
    • receivedDate is 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.
    • method must 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_batch takes up to 100 allocations and records one receipt for the money that arrived, with a PaymentAllocation row for each booking it settles, in one transaction — all of it lands or none does. A receipt over several bookings carries no single bookingId; 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.view or agents.ledger.view, the customer of the allocated booking, and the partner whose agency owns the booking or invoice. Row security on PaymentAllocation decides; 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 from FinanceConfig), the receipt voucher is posted approved and dated receivedDate, 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-document and 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, receiptNo and rejectionReason are overwritten on insert; createdBy is stamped from auth.uid().
  • On update, only notes and receiptUrl are editable — plus reference, sourceAccountId and method while 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.paidAmount is 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 by razorpay-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/:token sent 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-order refuses 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