Skip to content

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 Payment row 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 — 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. 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:

  1. Short-circuits if a LedgerEntry for (payment, paymentId, payment_received) already exists — belt-and-braces on top of the Payment unique index.
  2. Looks up the bank account from FinanceConfig.bankAccountId (falling back to code = '1010').
  3. Derives the customer debtor account code as CUS-<first-8-hex-of-customerId> (index.ts:109-110).
  4. Creates a JournalEntry with voucherNo = RV-YYYYMMDD-<6hex>, status = 'pending', referenceType = 'payment'.
  5. Inserts two JournalLine rows (Dr Bank, Cr Debtor) and a LedgerEntry mirror 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:

  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).
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.

Frontend TODO — order creation

Before Razorpay goes live, the frontend must:

  1. Create a Razorpay order via a server helper (an edge function, most likely) that calls Razorpay's Orders API with the service credentials.
  2. On order creation, set notes: { bookingId: '<booking-uuid>' }. The webhook reads payload.payment.entity.notes.bookingId (index.ts:278-289) — without it, the webhook returns 400 and no receipt is recorded.
  3. Open Razorpay Checkout with the returned order_id.
  4. 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.

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. Deploy: supabase functions deploy razorpay-webhook.
  5. Trigger a test event from the Razorpay dashboard and confirm a 200 response in the function logs.