Skip to content

Online payment (Razorpay)

Two edge functions and one table. Rules: FIN-032, TRV-009, PTR-081, ACC-063. The native app calls the function directly; the partner web portal calls it through POST /portals/agent/payments/online-order (Partner payments, PTR-093) and opens Razorpay Checkout in the page (src/lib/razorpayCheckout.ts, checkout.js loaded on demand). Set-up and the webhook's internals: Integrations → Razorpay.

The app records no payment. razorpay-order creates the order and one OnlinePaymentOrder row; Razorpay Checkout runs in a WebView on the phone (in the page on the web); razorpay-webhook records the verified Payment when Razorpay's signed payment.captured event arrives, and marks the order paid. A receipt a person claims stays pending (FIN-032); one proven by Razorpay's signature is recorded verified by the system, as the webhook has always done.

POST /functions/v1/razorpay-order

Bearer: the caller's Supabase session. verify_jwt is off; the function checks the token itself (getAuthContext: user active, roles loaded), like push-send.

Input — { bookingId, amount, purpose? }. amount is rupees (a whole number of paise, at least ₹1); purpose is free text kept on the order's notes (120 characters).

Who may call it — online_payment_actor(p_booking_id) in the database, with the caller's own session, answers one of:

Answer Who
customer the booking's customer or payer (auth_customer_booking_ids())
partner the booking's agency (Booking.agentId = auth_agent_id())
null anyone else, and anyone for a deleted booking — refused

Staff do not pay online (DECIDED 2026-09-30, FIN-032). No staff right — finance.payments.record included — makes a login anybody here (20261003180000_staff_do_not_pay_online.sql). A staff login that is null to the booking gets 403 "Staff don't pay online. Record the customer's payment instead."; a portal login gets 403 "This booking is not yours to pay.". A staff member whose own customer record is the booking's customer or payer is customer and may pay, like any traveller. The answer staff from an older database is refused the same way. A partner's login that also holds a staff right is still the partner (PTR-004).

Checks, in order — throttle (five refusals in fifteen minutes lock the login, the same auth_login_throttle as signing in, action razorpay_order); the body; the actor; the booking exists, is not cancelled or rejected, is priced in INR; the amount is no more than the booking's agreed due — price + GST − receipts − cancellation credit, the computed field booking_agreed_due — whether or not finance has approved the booking voucher (FIN-033, owner 01/10/2026; a database without the field falls back to Booking.balanceAmount). The office records anything else at the desk. At most ten orders still created by this login in fifteen minutes.

What it does — POST https://api.razorpay.com/v1/orders with basic auth (RAZORPAY_KEY_ID:RAZORPAY_KEY_SECRET), amount in paise, receipt = booking number + timestamp (40 characters), notes.bookingId (what the webhook reads), notes.bookingNo, notes.actor (user id), notes.actorKind, notes.purpose. Then inserts the OnlinePaymentOrder row with the service role.

Output

{
  "orderId": "order_XXXXXXXXXXXXXX",
  "amount": 5000000,
  "currency": "INR",
  "keyId": "rzp_live_…",
  "bookingNo": "ALH-2026-00012",
  "name": "Alhuda Travels",
  "description": "Booking ALH-2026-00012",
  "prefill": { "name": "…", "email": "…", "contact": "…" }
}

amount is paise. prefill is the payer's own record (the customer for a customer call, the agency for a partner call).

Status When
400 the amount, the balance, a cancelled booking, a booking not in INR — the sentence is for the screen
401 / 403 no session; nobody to this booking; a staff login (record the payment instead)
404 the booking was not found
429 throttled (retryAfter seconds, and Retry-After), or too many open orders
502 Razorpay refused or could not be reached
503 { error: "Online payment is not switched on yet — use bank transfer / UPI and tell us the reference.", notSwitchedOn: true } — RAZORPAY_KEY_ID or RAZORPAY_KEY_SECRET is not set

The 503 is deliberate (PLT-013): with no secrets the function refuses and says so; the app shows the sentence and points to I have paid.

COMM-034. The public page /pay/:token posts { payToken } and nothing else, with no session (the anonymous key). The token is the proof: whatsapp-webhook made it with whatsapp_pay_link_issue() for the booking's customer or payer whose number asked, for the agreed due then (fin_booking_agreed_due(), approved or not).

Checks, in order — the token's shape (base64url, 32–200 characters; else 400); the Razorpay secrets (else the same 503); ten link openings an hour per connection (public_form_rate_limit_hit; else 429); whatsapp_pay_link_use() spends the link — once, within 30 minutes, not replaced by a newer one (else 410 { error, reason } with reason none / used / superseded / expired, and a sentence that says how to get a new link); the booking is not deleted, cancelled, rejected or transferred, is in INR, and the link's amount is still no more than the agreed due (else 400).

What it does — the same Razorpay order as above, with notes.actor = whatsapp:<customer id>, notes.actorKind = customer, notes.channel = whatsapp_link; the order id is kept on the link (whatsapp_pay_link_order); an OnlinePaymentOrder row with createdBy = whatsapp:<customer id>, actorKind = customer, purpose = WhatsApp payment link. If Razorpay refuses, the link is released (whatsapp_pay_link_release) and can be opened again until it expires. The output is the same as above; prefill is the paying customer's record. Never staff (FIN-032).

POST /functions/v1/razorpay-webhook

Unchanged in what it records (see Integrations → Razorpay): on payment.captured, a Payment with status = 'verified', method = 'CARD', razorpayPaymentId, razorpayOrderId, createdBy = 'system:razorpay-webhook', deduplicated on razorpayPaymentId. The voucher is posted by the database's own receipt routine, fin_post_payment_receipt: Dr the collection account, Cr the payer's receivable (the partner's for a partner booking), converted to INR, and a retry returns the voucher already posted. fin_refresh_receipt_targets then brings the booking's paid and balance up to date. Until 30/09/2026 the webhook built its own voucher against a customer account even when a partner paid, and never refreshed the booking, so it showed the full amount still due (FIN-040). After the insert it sets the matching OnlinePaymentOrder to status = 'paid' with paymentId and paidAt — best effort; the Payment is the record of the money.

OnlinePaymentOrder

Column Meaning
id Razorpay's order id
bookingId, bookingNo the booking the order pays
amount, currency rupees, INR
receipt, purpose what was sent to Razorpay
createdBy, actorKind the login, and customer / partner (staff only on orders from before 30 Sep 2026)
status created → paid (the webhook); an abandoned order stays created and expires at Razorpay
paymentId, paidAt the Payment the webhook recorded

Written by the service role only (authenticated has SELECT and nothing else). Read under RLS by the booking's customer (auth_customer_booking_ids()), its partner (auth_agent_booking_ids()) and staff with finance.view or bookings.view. The anonymous key reaches neither the table nor online_payment_actor().

The app polls this row (then Payment by razorpayOrderId) for up to a minute after Checkout reports success, and shows "started, waiting for Razorpay" for an order under an hour old that is still created.

Where to look

Concern Path
Order function supabase/functions/razorpay-order/index.ts (+ index.test.ts)
Webhook supabase/functions/razorpay-webhook/index.ts (+ index.test.ts)
Table, actor function, throttle action supabase/migrations/20260929130000_an_online_payment_starts_with_an_order.sql; staff removed in 20261003180000_staff_do_not_pay_online.sql
Database test supabase/tests/an_online_payment_starts_with_an_order.sql, supabase/tests/staff_do_not_pay_online.sql
App apps/mobile/src/lib/razorpay.ts (+ razorpay.test.ts), apps/mobile/src/components/PayOnlineSheet.tsx
Web (partner portal) src/lib/partnerPayments.ts, src/lib/razorpayCheckout.ts, src/components/partner/PartnerPaymentDialogs.tsx