From 2bee2959e377a0d205fa64a6df1ed200270e4ec7 Mon Sep 17 00:00:00 2001 From: Dhruvil Mehta <68022411+dhruvilmehta@users.noreply.github.com> Date: Wed, 10 Apr 2024 23:44:27 -0700 Subject: [PATCH] added course payment and auth system --- package.json | 1 + .../20240402232925_purchase/migration.sql | 54 +++++ prisma/schema.prisma | 30 +++ src/app/api/auth/register/route.ts | 36 ++++ src/app/api/razorpay/route.ts | 83 ++++++++ src/app/api/razorpay/verify/route.ts | 80 ++++++++ src/app/new-courses/[courseId]/page.tsx | 66 ++++++ src/app/new-courses/page.tsx | 60 ++++++ src/app/receipts/page.tsx | 57 +++++ src/app/signup/page.tsx | 15 ++ src/components/Appbar.tsx | 98 ++++----- src/components/Signup.tsx | 194 ++++++++++++++++++ src/components/razorpay/Razorpay.tsx | 102 +++++++++ src/lib/auth.ts | 12 +- 14 files changed, 837 insertions(+), 51 deletions(-) create mode 100644 prisma/migrations/20240402232925_purchase/migration.sql create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/razorpay/route.ts create mode 100644 src/app/api/razorpay/verify/route.ts create mode 100644 src/app/new-courses/[courseId]/page.tsx create mode 100644 src/app/new-courses/page.tsx create mode 100644 src/app/receipts/page.tsx create mode 100644 src/app/signup/page.tsx create mode 100644 src/components/Signup.tsx create mode 100644 src/components/razorpay/Razorpay.tsx diff --git a/package.json b/package.json index f6f4ce667..bbf1b17e4 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "next-themes": "^0.2.1", "node-fetch": "^3.3.2", "notion-client": "^6.16.0", + "razorpay": "^2.9.2", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.50.1", diff --git a/prisma/migrations/20240402232925_purchase/migration.sql b/prisma/migrations/20240402232925_purchase/migration.sql new file mode 100644 index 000000000..77efa2af6 --- /dev/null +++ b/prisma/migrations/20240402232925_purchase/migration.sql @@ -0,0 +1,54 @@ +-- AlterTable +ALTER TABLE "Course" ADD COLUMN "price" INTEGER NOT NULL DEFAULT 1000; + +-- CreateTable +CREATE TABLE "Receipt" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "courseId" INTEGER NOT NULL DEFAULT 0, + "razorpayOrderId" TEXT, + + CONSTRAINT "Receipt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Purchase" ( + "id" TEXT NOT NULL, + "receiptId" TEXT NOT NULL, + "razorpay_payment_id" TEXT NOT NULL, + "razorpay_order_id" TEXT NOT NULL, + "razorpay_signature" TEXT NOT NULL, + "purchasedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "paymentVerified" BOOLEAN NOT NULL DEFAULT false, + "purchasedCourseId" INTEGER NOT NULL, + "purchasedById" TEXT NOT NULL, + + CONSTRAINT "Purchase_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Receipt_razorpayOrderId_key" ON "Receipt"("razorpayOrderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Purchase_receiptId_key" ON "Purchase"("receiptId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Purchase_razorpay_payment_id_key" ON "Purchase"("razorpay_payment_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Purchase_razorpay_order_id_key" ON "Purchase"("razorpay_order_id"); + +-- AddForeignKey +ALTER TABLE "Receipt" ADD CONSTRAINT "Receipt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Receipt" ADD CONSTRAINT "Receipt_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_receiptId_fkey" FOREIGN KEY ("receiptId") REFERENCES "Receipt"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_purchasedCourseId_fkey" FOREIGN KEY ("purchasedCourseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_purchasedById_fkey" FOREIGN KEY ("purchasedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a4bc79d74..2a507b801 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,6 +10,7 @@ datasource db { model Course { id Int @id @default(autoincrement()) + price Int @default(1000) appxCourseId Int discordRoleId String title String @@ -20,6 +21,8 @@ model Course { content CourseContent[] purchasedBy UserPurchases[] bookmarks Bookmark[] + purchases Purchase[] + receipts Receipt[] } model UserPurchases { @@ -132,6 +135,33 @@ model User { password String? appxUserId String? appxUsername String? + transactions Purchase[] + receipts Receipt[] +} + +model Receipt{ + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String + course Course @relation(fields: [courseId], references: [id]) + courseId Int @default(0) + razorpayOrderId String? @unique + purchase Purchase? +} + +model Purchase { + id String @id @default(cuid()) + receipt Receipt @relation(fields: [receiptId], references: [id]) + receiptId String @unique + razorpay_payment_id String @unique + razorpay_order_id String @unique + razorpay_signature String + purchasedAt DateTime @default(now()) + paymentVerified Boolean @default(false) + purchasedCourse Course @relation(fields: [purchasedCourseId], references: [id]) + purchasedCourseId Int + purchasedBy User @relation(fields: [purchasedById], references: [id]) + purchasedById String } model DiscordConnect { diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 000000000..8efbf1b82 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import db from '@/db'; +import bcrypt from 'bcrypt'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +export async function POST(req: NextRequest) { + try { + const { name, email, password } = await req.json(); + + const hashedPassword = await bcrypt.hash(password, 10); + await db.user.create({ + data: { + email, + name, + password: hashedPassword, + }, + }); + + return NextResponse.json( + { message: 'Account created sucessfully' }, + { status: 201 }, + ); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + return NextResponse.json( + { error: 'Email already taken.' }, + { status: 400 }, + ); + } + console.log(e); + return NextResponse.json( + { error: 'Failed to parse JSON input' }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/razorpay/route.ts b/src/app/api/razorpay/route.ts new file mode 100644 index 000000000..e06dd515a --- /dev/null +++ b/src/app/api/razorpay/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Razorpay from 'razorpay'; +import db from '@/db'; +import { z } from 'zod'; + +const schema = z.object({ + courseId: z.number(), + userId: z.string(), +}); + +const razorpay = new Razorpay({ + key_id: process.env.RAZORPAY_KEY_ID!, + key_secret: process.env.RAZORPAY_SECRET!, +}); + +export async function POST(req: NextRequest) { + const reqBody = await req.json(); + const body = schema.safeParse(reqBody); + + if (!body.success) { + return NextResponse.json( + { + error: 'Error parsing the body of the request', + }, + { + status: 422, + }, + ); + } + + const course = await db.course.findFirst({ + where: { + id: body.data.courseId, + }, + }); + + if (!course) + return NextResponse.json({ error: 'Course Not Found' }, { status: 404 }); + + const receipt = await db.receipt.create({ + data: { + userId: body.data.userId, + courseId: body.data.courseId, + }, + }); + + const payment_capture = 1; + const amount = course.price; + const options = { + amount: (amount * 100).toString(), + currency: 'INR', + receipt: receipt.id, + payment_capture, + notes: { + userId: body.data.userId, + courseId: body.data.courseId, + receipt: receipt.id, + }, + }; + + try { + const response = await razorpay.orders.create(options); + + await db.receipt.update({ + where: { + id: receipt.id, + }, + data: { + razorpayOrderId: response.id, + }, + }); + + return NextResponse.json({ + id: response.id, + currency: response.currency, + amount: response.amount, + razorPayKey: process.env.RAZORPAY_KEY_ID, + }); + } catch (err) { + console.log(err); + return NextResponse.json(err); + } +} diff --git a/src/app/api/razorpay/verify/route.ts b/src/app/api/razorpay/verify/route.ts new file mode 100644 index 000000000..aad654f4b --- /dev/null +++ b/src/app/api/razorpay/verify/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validatePaymentVerification } from 'razorpay/dist/utils/razorpay-utils'; +import { z } from 'zod'; +import db from '@/db'; + +const razorPayZodSchema = z.object({ + userId: z.string(), + courseId: z.number(), + razorpay_order_id: z.string(), + razorpay_payment_id: z.string(), + razorpay_signature: z.string(), +}); + +export async function POST(req: NextRequest) { + const jsonBody = await req.json(); + + const body = razorPayZodSchema.safeParse(jsonBody); + + if (!body.success) { + return NextResponse.json( + { + error: 'Invalid Body', + }, + { status: 422 }, + ); + } + + const { + razorpay_order_id, + razorpay_payment_id, + razorpay_signature, + courseId, + userId, + } = body.data; + + const isPaymentValid = validatePaymentVerification( + { + order_id: razorpay_order_id, + payment_id: razorpay_payment_id, + }, + razorpay_signature, + process.env.RAZORPAY_SECRET!, + ); + + if (!isPaymentValid) + return NextResponse.json( + { error: 'Payment not verified' }, + { status: 404 }, + ); + + const receipt = await db.receipt.findFirst({ + where: { + razorpayOrderId: razorpay_order_id, + }, + }); + + if (!receipt) + return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }); + + await db.purchase.create({ + data: { + receiptId: receipt.id, + razorpay_order_id, + razorpay_payment_id, + razorpay_signature, + paymentVerified: isPaymentValid, + purchasedById: userId, + purchasedCourseId: courseId, + }, + }); + + await db.userPurchases.create({ + data: { + userId, + courseId, + }, + }); + + return NextResponse.json({ message: 'Purchase Successful' }, { status: 200 }); +} diff --git a/src/app/new-courses/[courseId]/page.tsx b/src/app/new-courses/[courseId]/page.tsx new file mode 100644 index 000000000..daf0e9bc1 --- /dev/null +++ b/src/app/new-courses/[courseId]/page.tsx @@ -0,0 +1,66 @@ +import { RazorPayComponent } from '@/components/razorpay/Razorpay'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import Image from 'next/image'; +import db from '@/db'; +import { authOptions } from '@/lib/auth'; +import { getServerSession } from 'next-auth'; +import { redirect } from 'next/navigation'; + +export default async function CourseDetails({ + params, +}: { + params: { courseId: string[] }; +}) { + const session = await getServerSession(authOptions); + if (!session?.user) { + redirect('/signin'); + } + const course = await db.course.findFirst({ + where: { + id: Number(params.courseId), + }, + }); + if (!course) return