Building a Learning Management System (LMS) like Ascend requires more than just displaying videos; it requires a seamless, secure way for users to purchase content.
In this article, I’ll walk you through how I integrated Razorpay into my MERN stack project, ensuring that every transaction is verified on the backend before granting course access.
The Workflow
Before diving into code, it’s important to understand the handshake between your app and Razorpay. We don't just trust the frontend; we verify every payment signature on our server to prevent fraud.
1. Frontend Setup: Preparing the UI
First, we need the Razorpay checkout script in our index.html. This injects the global Razorpay object into our window.
<script src="https://checkout.razorpay.com/v1/checkout.js"></script>
TypeScript Definitions
Since I built Ascend with TypeScript, I needed to define the Razorpay types to avoid the dreaded "property does not exist on window" error. I created a src/types/razorpay.d.ts file:
export {};
declare global {
interface Window {
Razorpay: new (options: RazorpayOptions) => RazorpayInstance;
}
interface RazorpayOptions {
key: string;
amount: number;
currency: string;
name?: string;
description?: string;
order_id?: string;
handler?: (response: RazorpayResponse) => void;
prefill?: {
name?: string;
email?: string;
contact?: string;
};
theme?: {
color?: string;
};
}
interface RazorpayInstance {
open(): void;
on(event: string, callback: () => void): void;
}
interface RazorpayResponse {
razorpay_payment_id: string;
razorpay_order_id: string;
razorpay_signature: string;
}
}
2. Backend Phase 1: Creating the Order
When a student clicks "Enroll," we don't just open the modal. We first ask our server to create a Razorpay Order. This ensures the price is fetched from our database, not sent from the client (where it could be tampered with).
The Route:
userRouter.post("/purchase-rzp", protect, wrapAsync(purchaseCourseRZP));
The Controller:
In purchaseCourseRZP, we calculate the final price, create a "pending" purchase record in our DB, and then initialize the Razorpay order.
export const purchaseCourseRZP = async (req, res) => {
const { courseId } = req.body;
const { userId } = await req.auth();
const userData = await User.findById(userId);
const courseData = await Course.findById(courseId);
if (!userData || !courseData) {
throw new ExpressError(404, "Data not found");
}
const finalAmount =
courseData.coursePrice -
(courseData.discount * courseData.coursePrice) / 100;
// Create a record in our database first
const newPurchase = new Purchase({
courseId: courseData._id,
userId,
amount: finalAmount,
status: "pending",
});
await newPurchase.save();
// initialize razorpay
const razorpayInstance = new Razorpay({
key_id: process.env.RZP_KEY_ID,
key_secret: process.env.RZP_KEY_SECRET,
});
const options = {
amount: Math.round(finalAmount * 100), // paisa
currency: "INR",
receipt: `receipt_${newPurchase._id}`,
notes: {
purchaseId: newPurchase._id.toString(),
},
};
const order = await razorpayInstance.orders.create(options);
res.json({
success: true,
orderId: order.id,
amount: order.amount,
key: process.env.RZP_KEY_ID,
purchaseId: newPurchase._id,
});
};
3. Frontend Phase 2: Opening the Modal
Once the backend returns the orderId, we trigger the Razorpay popup in CourseDetails.tsx.
const enrollCourse = async () => {
try {
if (!userData) return toast.error("Login to enroll");
if (isAlreadyEnrolled) return toast.error("Already enrolled");
const token = await getToken();
if (!token) return toast.error("Unauthorized");
const { data } = await axios.post(
backendUrl + "/api/user/purchase-rzp",
{ courseId: courseData?._id },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!data.success) return toast.error("Something went wrong");
const options = {
key: data.key,
amount: data.amount,
currency: "INR",
order_id: data.orderId,
// This runs after a successful payment
handler: async (response: RazorpayResponse) => {
await axios.post(
backendUrl + "/api/user/verify-rzp",
{
...response,
purchaseId: data.purchaseId,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
window.location.replace("/loading/my-enrollments");
},
};
const rzp = new window.Razorpay(options);
rzp.open();
} catch (error) {
const msg =
error instanceof Error ? error.message : "Something went wrong";
toast.error(msg);
}
};
4. Backend Phase 3: The Critical Security Check
This is where the magic happens. When the user pays, Razorpay gives them a razorpay_signature. We must verify this signature on our server using our RZP_KEY_SECRET to confirm the payment is legitimate.
The Verification Logic:
We use HMAC SHA256 to create a signature on our end and compare it with the one provided by the frontend.
export const verifyRazorpayPayment = async (req, res) => {
const {
razorpay_order_id,
razorpay_payment_id,
razorpay_signature,
purchaseId,
} = req.body;
const body = razorpay_order_id + "|" + razorpay_payment_id;
const expectedSignature = crypto
.createHmac("sha256", process.env.RZP_KEY_SECRET)
.update(body)
.digest("hex");
if (expectedSignature !== razorpay_signature) {
await Purchase.findByIdAndUpdate(purchaseId, {
status: "failed",
});
throw new ExpressError(400, "Invalid signature");
}
const purchaseData = await Purchase.findById(purchaseId);
const userData = await User.findById(purchaseData.userId);
const courseData = await Course.findById(purchaseData.courseId);
// enroll student
courseData.enrolledStudents.push(userData._id);
await courseData.save();
userData.enrolledCourses.push(courseData._id);
await userData.save();
purchaseData.status = "completed";
purchaseData.paymentId = razorpay_payment_id;
await purchaseData.save();
res.json({ success: true });
};
Summary
By following this flow, Ascend ensures that:
- Price Integrity: Prices are calculated server-side.
- Atomicity: A purchase record is created before the payment starts.
- Security: No student is enrolled unless the cryptographic signature from Razorpay is verified.
Top comments (0)