Skip to content

Razorpay

Razorpay is the Indian payment gateway for customer-facing online payments (card, UPI, netbanking). Two functions: razorpay-order creates the order for the native app's Pay online (API → Online payment); razorpay-webhook (this page) records the money.

Switched on by the owner

The code is complete and tested; production has no Razorpay secrets yet. Until RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET are set, razorpay-order answers 503 and the app says "Online payment is not switched on yet — use bank transfer / UPI and tell us the reference". Until RAZORPAY_WEBHOOK_SECRET is set, the webhook answers 500 and Razorpay retries. Set all three together (checklist below). The web customer portal has no Pay online button; only the app does.

Purpose

Customer receipts captured online need to flow into the double-entry ledger the same way manual receipts do:

  • Record a verified Payment row with the Razorpay payment id.
  • Post the receipt journal — Dr the collection account, Cr the payer's own receivable (the partner's for a partner booking), and bring the booking's paid and balance up to date.
  • Be idempotent against Razorpay's retry storms (4xx / 5xx responses are retried aggressively by their platform).

Webhook implementation

File: supabase/functions/razorpay-webhook/index.ts.

Signature verification

Razorpay signs the raw request body with HMAC-SHA256 using the shared secret and sends the hex digest in the X-Razorpay-Signature header. We re-compute the digest and compare in constant time (supabase/functions/razorpay-webhook/index.ts:63-93):

export async function verifyRazorpaySignature(
  rawBody: string,
  signatureHeader: string | null,
  secret: string,
): Promise<boolean> {
  if (!signatureHeader || !secret) return false;
  // ...HMAC-SHA256 over rawBody, hex-encode, timing-safe compare
}

Critical detail: index.ts:389-395 reads await req.text() before JSON parsing. HMAC is over the exact bytes — reparsing and reserialising would change whitespace and break verification.

verify_jwt must stay off

supabase/config.toml:87-88 sets verify_jwt = false for this function. Razorpay does not send a Supabase JWT; the HMAC signature check is the authentication. Re-enabling the gateway check would 401 every webhook before our code sees it.

Event handling

handleRequest (index.ts:378-424) routes on the event field:

  • payment.captured — calls handlePaymentCaptured. All other work happens here.
  • Anything else (payment.authorized, payment.failed, order.paid, refund.*, ...) — returns 200 { ok: true, ignored: true, event } so Razorpay stops retrying.

Data flow for payment.captured

Razorpay → POST /razorpay-webhook
        │
        ├─ 1. Read raw body, read X-Razorpay-Signature header
        ├─ 2. verifyRazorpaySignature() — 401 on mismatch
        ├─ 3. JSON.parse the body
        ├─ 4. Extract payment.entity (id, order_id, amount, currency, notes)
        ├─ 5. Validate notes.bookingId is present         (400 if missing)
        ├─ 6. Dedup: SELECT Payment WHERE razorpayPaymentId = :id
        │     └─ hit → 200 { deduplicated: true, paymentId }
        ├─ 7. Load Booking (id, bookingNo, customerId, payerId)
        │     └─ miss → 200 { ok: false, reason: "booking_not_found" }
        ├─ 8. INSERT Payment (status='verified', method='CARD', razorpay*)
        │     └─ unique-violation on razorpayPaymentId  → dedup success
        └─ 9. fin_post_payment_receipt + fin_refresh_receipt_targets — see below

Verified against handlePaymentCaptured in index.ts:268-374.

Journal posting

The webhook does not build a voucher itself. After recording the Payment it calls the database's own receipt routines, the ones verify_payment uses:

  1. fin_post_payment_receipt(paymentId, null) — one voucher, Dr the collection account (FinanceConfig bank), Cr the payer's receivable: the partner's AGR- account on a partner booking, the customer's otherwise. It converts a foreign-currency receipt to INR and is idempotent: a retry returns the voucher already posted.
  2. fin_refresh_receipt_targets(paymentId) — the booking's paidAmount and balanceAmount (fin_booking_billed() − paid − cancellation credit: the price + GST count only once finance approves the booking voucher, FIN-033), and any invoice the receipt pays.

What can be paid online. razorpay-order caps an order at the booking's agreed due — price + GST − receipts − cancellation credit (fin_booking_agreed_due(), the computed field booking_agreed_due) — not at balanceAmount. So a customer or partner can pay before finance approves the booking (owner, 01/10/2026); the payment is recorded as above and the balance catches up when the voucher is approved. "Nothing is due on this booking." only when the agreed due is 0.

The function returns { journalEntryId }, or { skipped: reason } if the voucher could not be posted; the money is counted either way and the Payment stays verified. Until 30/09/2026 postReceiptJournal built the voucher here against CUS-<customer> even when a partner paid, with no conversion, and the booking was never refreshed (FIN-040).

Idempotency summary

Three independent guards:

  1. Payment.razorpayPaymentId has a partial unique index (see migration below). A duplicate insert raises 23505 and is treated as dedup success (index.ts:340-352).
  2. A LedgerEntry existence check prevents double-posting the journal if the Payment insert succeeded but the journal failed on a previous attempt.
  3. Unknown event types return 200 ignored so Razorpay's retry queue does not compound non-issues.

Required environment variables

Variable Location Purpose
RAZORPAY_WEBHOOK_SECRET Supabase function secrets Shared secret used to HMAC-sign the payload. Must match the value set in the Razorpay dashboard (Settings → Webhooks).
RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET Supabase function secrets (razorpay-order) The API key pair from the Razorpay dashboard (Settings → API Keys). The key id is handed to the phone for Checkout; the secret authenticates the Orders API and never leaves the function.
SUPABASE_URL Function runtime (platform-provided) Used by getServiceClient() inside the function.
SUPABASE_SERVICE_ROLE_KEY Function runtime (platform-provided) Same as above.

Set the webhook secret once with:

supabase secrets set RAZORPAY_WEBHOOK_SECRET=<same-value-as-in-razorpay-dashboard>

Production secret rotation

Rotating RAZORPAY_WEBHOOK_SECRET requires updating both the Razorpay dashboard and supabase secrets in the same window. Any webhook that arrives with the old signature while only one side is rotated will 401 and Razorpay will retry — usually fine, but don't leave the two mismatched for long.

Migration

supabase/migrations/20260418114458_razorpay_payment_fields.sql adds:

  • Payment.razorpayPaymentId TEXT
  • Payment.razorpayOrderId TEXT
  • Payment.razorpaySignature TEXT
  • Partial unique index Payment_razorpayPaymentId_key ON Payment (razorpayPaymentId) WHERE razorpayPaymentId IS NOT NULL

The partial predicate lets non-Razorpay payments keep NULL in the column without colliding.

Order creation — razorpay-order

supabase/functions/razorpay-order/index.ts does what the webhook needs:

  1. Creates the Razorpay order through the Orders API with RAZORPAY_KEY_ID:RAZORPAY_KEY_SECRET (basic auth), for the booking's customer or payer, or its partner — never for staff, who record the customer's payment instead (FIN-032, DECIDED 2026-09-30; online_payment_actor() in the database decides), capped at the booking's balance, throttled like signing in.
  2. Sets notes: { bookingId, bookingNo, actor, actorKind, purpose? }. The webhook reads notes.bookingId — without it the webhook returns 400 and no receipt is recorded.
  3. Writes an OnlinePaymentOrder row (created); the webhook sets it to paid with the Payment id. The app opens Razorpay Checkout in a WebView with the returned order_id and, on success, shows "confirming" and polls that row for up to a minute — the browser callback is never trusted; the webhook is the source of truth.

Full contract: API → Online payment.

Order amount is in paise

The webhook divides payload.payment.entity.amount by 100 to get rupees. razorpay-order takes rupees from the app and sends paise to Razorpay.

Testing

Unit tests: supabase/functions/razorpay-webhook/index.test.ts. Covers signature roundtrip, signature rejection, missing secret, unknown event types, HTTP-method rejection, CORS preflight, and the dedup short-circuit with a stub Supabase client.

deno test --allow-env --allow-net \
  supabase/functions/razorpay-webhook/index.test.ts

Setup checklist

  1. In the Razorpay dashboard → Settings → Webhooks, point at https://<project-ref>.functions.supabase.co/razorpay-webhook and subscribe to payment.captured at minimum.
  2. Pick a strong random secret; paste it into the dashboard webhook config.
  3. supabase secrets set RAZORPAY_WEBHOOK_SECRET=<same-secret>.
  4. supabase secrets set RAZORPAY_KEY_ID=<key id> RAZORPAY_KEY_SECRET=<key secret> (Settings → API Keys; use the test pair first, then the live pair).
  5. Deploy: supabase functions deploy razorpay-webhook razorpay-order.
  6. Trigger a test event from the Razorpay dashboard and confirm a 200 response in the function logs; then pay ₹1 from the app on a test booking and confirm the Payment row and the OnlinePaymentOrder set to paid.