Razorpay
Razorpay is the Indian payment gateway we intend to use for customer-facing online payments (card, UPI, netbanking). A webhook receiver is scaffolded and fully covered by unit tests, but no checkout / order-creation flow exists yet in the frontend.
Integration scaffolded, not yet live
Razorpay is not currently live in production. The webhook handler, database columns, and dedup logic are all in place so that the switch-on work is a frontend-only change (order creation + redirect). This is an explicit product decision: payment gateway integration will happen in a later phase.
Purpose
Customer receipts captured online need to flow into the double-entry ledger the same way manual receipts do:
- Record a verified
Paymentrow with the Razorpay payment id. - Post the receipt journal — Dr Bank, Cr Customer Debtor.
- 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— callshandlePaymentCaptured. All other work happens here.- Anything else (
payment.authorized,payment.failed,order.paid,refund.*, ...) — returns200 { 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. postReceiptJournal() — see below
Verified against handlePaymentCaptured in index.ts:268-374.
Journal posting
postReceiptJournal (index.ts:119-248) mirrors the non-agent, bank-based
subset of postVerifiedPaymentFinanceEntries in
src/lib/financeOperations.ts. It:
- Short-circuits if a
LedgerEntryfor(payment, paymentId, payment_received)already exists — belt-and-braces on top of thePaymentunique index. - Looks up the bank account from
FinanceConfig.bankAccountId(falling back tocode = '1010'). - Derives the customer debtor account code as
CUS-<first-8-hex-of-customerId>(index.ts:109-110). - Creates a
JournalEntrywithvoucherNo = RV-YYYYMMDD-<6hex>,status = 'pending',referenceType = 'payment'. - Inserts two
JournalLinerows (Dr Bank, Cr Debtor) and aLedgerEntrymirror for read-model queries.
The function returns { journalEntryId } on success or { skipped: reason }
if any lookup failed. A skipped journal still leaves the Payment row
verified — finance can reconcile manually from the Razorpay dashboard.
Idempotency summary
Three independent guards:
Payment.razorpayPaymentIdhas a partial unique index (see migration below). A duplicate insert raises23505and is treated as dedup success (index.ts:340-352).- A
LedgerEntryexistence check prevents double-posting the journal if the Payment insert succeeded but the journal failed on a previous attempt. - Unknown event types return
200 ignoredso 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). |
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:
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 TEXTPayment.razorpayOrderId TEXTPayment.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.
Frontend TODO — order creation
Before Razorpay goes live, the frontend must:
- Create a Razorpay order via a server helper (an edge function, most likely) that calls Razorpay's Orders API with the service credentials.
- On order creation, set
notes: { bookingId: '<booking-uuid>' }. The webhook readspayload.payment.entity.notes.bookingId(index.ts:278-289) — without it, the webhook returns 400 and no receipt is recorded. - Open Razorpay Checkout with the returned
order_id. - On checkout success, show a pending state and wait for the webhook to flip the booking's paid amount (do not trust the browser callback alone — the webhook is the source of truth).
Order amount is in paise
The webhook divides payload.payment.entity.amount by 100 to get rupees
(index.ts:315). Orders must be created with paise amounts accordingly.
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.
Setup checklist
- In the Razorpay dashboard → Settings → Webhooks, point at
https://<project-ref>.functions.supabase.co/razorpay-webhookand subscribe topayment.capturedat minimum. - Pick a strong random secret; paste it into the dashboard webhook config.
supabase secrets set RAZORPAY_WEBHOOK_SECRET=<same-secret>.- Deploy:
supabase functions deploy razorpay-webhook. - Trigger a test event from the Razorpay dashboard and confirm a 200 response in the function logs.