Razorpay
Category: Payments
Integration type: Platform-level (single Razorpay account for all tenants)
External SDK: razorpay (official Node.js SDK)
Purpose
Razorpay handles all payment processing for Leadmetrics:
- Subscription billing — Monthly/annual plan payments (Free/Pro/Agency/Enterprise)
- Credit top-ups — On-demand credit purchases beyond plan allocation
- Invoice generation — Automatic invoices linked to each payment
Razorpay is a platform-level integration — there is one Razorpay account owned by the platform operator. Tenants never configure their own Razorpay keys.
Config Structure
Platform config (env vars)
RAZORPAY_KEY_ID=rzp_live_xxxxxxxxxxxxxxxxxxxx
RAZORPAY_KEY_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
RAZORPAY_WEBHOOK_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx # For webhook signature verificationFor test/staging:
RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxxxxxxxxxx
RAZORPAY_KEY_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxIntegration Pattern
Plan subscription flow
Subscriptions are created in Razorpay and linked to tenant records:
import Razorpay from 'razorpay';
const razorpay = new Razorpay({
key_id: config.RAZORPAY_KEY_ID,
key_secret: config.RAZORPAY_KEY_SECRET,
});
// Create a plan (one-time setup — plans don't change often)
async function createPlan(options: {
name: string; // e.g. "Leadmetrics Pro Monthly"
amount: number; // In paise (INR × 100): 4999 rupees = 499900 paise
currency: string; // 'INR'
interval: number; // 1
period: string; // 'monthly' | 'yearly'
}): Promise<string> {
const plan = await razorpay.plans.create({
period: options.period,
interval: options.interval,
item: {
name: options.name,
amount: options.amount,
currency: options.currency,
},
});
return plan.id;
}
// Subscribe a tenant to a plan
async function createSubscription(
planId: string,
totalCount: number, // Number of billing cycles (e.g. 12 for annual)
): Promise<{ subscriptionId: string; shortUrl: string }> {
const subscription = await razorpay.subscriptions.create({
plan_id: planId,
total_count: totalCount,
quantity: 1,
notify_info: {
notify_phone: tenantPhone,
notify_email: tenantEmail,
},
});
return {
subscriptionId: subscription.id,
shortUrl: subscription.short_url, // Payment page URL
};
}Credit top-up order flow
async function createTopupOrder(options: {
credits: number; // Number of credits being purchased
amount: number; // Amount in paise
tenantId: string;
}): Promise<{ orderId: string; amount: number; currency: string }> {
const order = await razorpay.orders.create({
amount: options.amount,
currency: 'INR',
receipt: `topup-${options.tenantId}-${Date.now()}`,
notes: {
tenantId: options.tenantId,
credits: String(options.credits),
type: 'credit_topup',
},
});
// Also insert a pending row in credit_topup_orders
await db.insert(creditTopupOrders).values({
tenantId: options.tenantId,
orderId: order.id,
credits: options.credits,
amountPaise: options.amount,
status: 'pending',
});
return {
orderId: order.id,
amount: order.amount,
currency: order.currency,
};
}Webhook handling
Razorpay sends webhooks for payment events. All events go through signature verification before processing:
// POST /api/webhooks/razorpay
async function handleRazorpayWebhook(req: Request): Promise<void> {
const signature = req.headers['x-razorpay-signature'] as string;
const isValid = Razorpay.validateWebhookSignature(
JSON.stringify(req.body),
signature,
config.RAZORPAY_WEBHOOK_SECRET,
);
if (!isValid) throw new UnauthorizedError('Invalid webhook signature');
const event = req.body as RazorpayWebhookEvent;
switch (event.event) {
case 'payment.captured':
await handlePaymentCaptured(event.payload.payment.entity);
break;
case 'subscription.charged':
await handleSubscriptionCharged(event.payload.subscription.entity);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(event.payload.subscription.entity);
break;
case 'payment.failed':
await handlePaymentFailed(event.payload.payment.entity);
break;
}
}Credit top-up on payment.captured
async function handlePaymentCaptured(payment: RazorpayPayment): Promise<void> {
const { tenantId, credits } = payment.notes;
// Update order status
await db.update(creditTopupOrders)
.set({ status: 'paid', razorpayPaymentId: payment.id, paidAt: new Date() })
.where(eq(creditTopupOrders.orderId, payment.order_id));
// Add credits to balance
await topupCredits(tenantId, Number(credits), payment.id);
// Generate invoice via Billing Agent
await billingQueue.add('generate-invoice', { tenantId, paymentId: payment.id });
// Notify tenant
await notifyTenant(tenantId, 'credit_topup_confirmed', { credits });
}Testing in Test Mode
Use rzp_test_* keys in .env. In test mode, no real money moves.
Test credentials — Razorpay Checkout (browser / E2E)
| Method | Credential | Notes |
|---|---|---|
| UPI ID | success@razorpay | Simulates instant success with no OTP. Best for browser/E2E testing. |
| UPI ID | failure@razorpay | Simulates UPI payment failure. |
| Card (no 3DS) | 5267 3181 8797 5449 / exp 02/26 / CVV 123 | Domestic Mastercard — completes without OTP popup. |
| Card (3DS) | 4208 3000 9609 2278 / OTP 1234 | Visa 3DS — opens a Razorpay-hosted OTP screen within the same tab. |
| Card (avoid) | 4111 1111 1111 1111 | Opens an external 3DS bank popup — Playwright cannot interact with it; use the cards above instead. |
E2E Playwright advice: Use
success@razorpayas the UPI ID. Switch to UPI tab in the Razorpay modal, type the UPI ID in the “Pay with UPI ID / Number” field, click “Verify and Pay”. The modal will show “Confirming Payment” then “Payment Successful” and auto-redirect after 4 seconds.
Test Cases
Unit tests (packages/billing/src/razorpay.test.ts)
| Test | Approach |
|---|---|
createTopupOrder() creates Razorpay order with correct amount | Mock razorpay.orders.create; assert amount, currency, receipt |
createTopupOrder() inserts pending row in DB | Assert credit_topup_orders row created with status: 'pending' |
handleRazorpayWebhook() rejects invalid signature | Pass wrong signature; assert 401 |
handlePaymentCaptured() credits tenant on payment | Mock payment.captured event; assert credit_balances updated |
handlePaymentFailed() sends failure notification | Mock failed payment; assert notifyTenant called |
Integration tests
| Test | Approach |
|---|---|
| Create order and simulate capture via Razorpay test mode | Use rzp_test_* keys; create order; simulate capture via Razorpay dashboard |
| Webhook signature validation | Use test secret; sign payload; assert accepted |
Currency and Pricing
All amounts are stored and transmitted in paise (INR × 100):
| Plan | Monthly (INR) | Amount in paise |
|---|---|---|
| Pro | ₹4,999 | 499900 |
| Agency | ₹12,999 | 1299900 |
| Enterprise | Custom | — |
Credit top-up bundles:
| Credits | Price | Paise |
|---|---|---|
| 10 | ₹499 | 49900 |
| 50 | ₹1,999 | 199900 |
| 150 | ₹4,999 | 499900 |
Related
- Credits Overview — credit system design
- Credit Tracking —
credit_topup_orderstable - Onboarding — subscription setup during registration