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.
A payment link from the WhatsApp menu — { payToken }
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 |