diff --git a/client/components/forms/patient/AppointmentForm.tsx b/client/components/forms/patient/AppointmentForm.tsx index 3bd7b9c0..2042ee45 100644 --- a/client/components/forms/patient/AppointmentForm.tsx +++ b/client/components/forms/patient/AppointmentForm.tsx @@ -11,7 +11,7 @@ import { SelectItem } from "@/components/ui/select"; import Image from "next/image"; import { AppointmentTypes } from "@/constants"; import { FormFieldType } from "@/types/fromTypes"; -import { useCreateAppointment, useGetDoctorsList } from "@/lib/hooks/appointment/useAppointment"; +import { useCompletePaymentAppointment, useCreateAppointment, useGetDoctorsList } from "@/lib/hooks/appointment/useAppointment"; import { useGetSlotsOfDoctor } from "@/lib/hooks/slots/useSlot"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -25,6 +25,7 @@ const AppointmentForm = () => { const { data: doctorsData, isLoading: isDoctorsLoading } = useGetDoctorsList(); const [isDoctorSelected, setIsDoctorSelected] = useState(false); const { mutate: createAppointment, isPending } = useCreateAppointment(); + const {mutate:completePayment} = useCompletePaymentAppointment() const query = useQueryClient(); const form = useForm>({ @@ -76,7 +77,7 @@ const AppointmentForm = () => { }, }, { - onSuccess() { + onSuccess: async({paymentSessionId})=> { toast({ title: "Appointment Created", description: "We will notify you once the doctor approves your appointment", diff --git a/client/lib/api/appointment/index.ts b/client/lib/api/appointment/index.ts index 8221a37e..8ba0f723 100644 --- a/client/lib/api/appointment/index.ts +++ b/client/lib/api/appointment/index.ts @@ -75,4 +75,9 @@ export const getDoctorsList = async () => { export const createAppointment = async (appointment:IAppointment)=>{ const response = await axiosInstance.post('/',{appointment}); return response.data; +} + +export const completePayment = async (data:any)=>{ + const response = await axiosInstance.put('/',data); + return response.data; } \ No newline at end of file diff --git a/client/lib/hooks/appointment/useAppointment.ts b/client/lib/hooks/appointment/useAppointment.ts index cff6ed85..7c61c0a5 100644 --- a/client/lib/hooks/appointment/useAppointment.ts +++ b/client/lib/hooks/appointment/useAppointment.ts @@ -1,8 +1,12 @@ -import { createAppointment, getDoctorsList } from "@/lib/api/appointment" +import { completePayment, createAppointment, getDoctorsList } from "@/lib/api/appointment" import IAppointment, { ErrorResponse, IDoctor, MessageResponse, PaginatedResult } from "@/types" import { useMutation, useQuery } from "@tanstack/react-query" import { AxiosError } from "axios" +interface MessageWithSessionId extends MessageResponse{ + paymentSessionId:string +} + export const useGetDoctorsList = () => { return useQuery, AxiosError>({ queryFn: () => getDoctorsList(), @@ -12,10 +16,19 @@ export const useGetDoctorsList = () => { }; export const useCreateAppointment = ()=>{ - return useMutation,{appointment:IAppointment}>({ + return useMutation,{appointment:IAppointment}>({ mutationFn:({appointment})=>createAppointment(appointment), onError:(error)=>{ console.log("Error in creating appointment",error); } }) +} + +export const useCompletePaymentAppointment = ()=>{ + return useMutation,{data:any}>({ + mutationFn:({data})=>completePayment(data), + onError:(error)=>{ + console.log("Error in completing payment ",error); + } + }) } \ No newline at end of file diff --git a/package.json b/package.json index a95e4dfd..f8e4163c 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,8 @@ }, "devDependencies": { "concurrently": "^8.2.2" + }, + "dependencies": { + "razorpay": "^2.9.4" } -} \ No newline at end of file +} diff --git a/server/src/domain/entities/IAppointment.ts b/server/src/domain/entities/IAppointment.ts index 14362503..949f8d93 100644 --- a/server/src/domain/entities/IAppointment.ts +++ b/server/src/domain/entities/IAppointment.ts @@ -20,5 +20,6 @@ export default interface IAppointment { readonly appointmentDate?: string; readonly reason?: string; readonly notes?: string; + readonly paymentId?: string; status?: AppointmentStatus; } diff --git a/server/src/domain/entities/IPayment.ts b/server/src/domain/entities/IPayment.ts new file mode 100644 index 00000000..66dd7631 --- /dev/null +++ b/server/src/domain/entities/IPayment.ts @@ -0,0 +1,18 @@ +export enum PaymentStatus { + PENDING = "PENDING", + COMPLETED = "COMPLETED", + FAILED = "FAILED", +} + +export default interface IPayment { + _id?: string; + orderId: string; + paymentId?: string; + appointmentId?: string; + amount?: number; + currency?: string; + status?: PaymentStatus; + createdAt?: Date; + updatedAt?: Date; + razorpaySignature?: string; +} diff --git a/server/src/domain/interface/repositories/IAppointmentRepository.ts b/server/src/domain/interface/repositories/IAppointmentRepository.ts index ae4ee078..f3bd0749 100644 --- a/server/src/domain/interface/repositories/IAppointmentRepository.ts +++ b/server/src/domain/interface/repositories/IAppointmentRepository.ts @@ -1,11 +1,12 @@ import IAppointment, { AppointmentStatus } from "../../entities/IAppointment"; export default interface IAppointmentRepository { - create(appointment: IAppointment): Promise; + create(appointment: IAppointment): Promise; update(appointment: IAppointment): Promise; updateStatusMany(appointmentIds: string[], status: AppointmentStatus): Promise; updateManyBySlotIds(slotIds: string[], fields: IAppointment): Promise; findOneBySlotId(slotId: string): Promise; findByDateAndSlot(appointmentDate: string, slotId:string): Promise; findManyByDateAndDoctorId(appointmentDate:string,doctorId:string):Promise; + updateAppointmentStatusToConfirmed(appointmentId:string):Promise } diff --git a/server/src/domain/interface/repositories/IPaymentRepository.ts b/server/src/domain/interface/repositories/IPaymentRepository.ts new file mode 100644 index 00000000..f163d455 --- /dev/null +++ b/server/src/domain/interface/repositories/IPaymentRepository.ts @@ -0,0 +1,8 @@ +import IPayment from "../../entities/IPayment"; + +export default interface IPaymentRepository { + create(payment: IPayment): Promise; + findById(id: string): Promise; + findByOrderId(orderId: string): Promise; + update(payment: IPayment): Promise; +} diff --git a/server/src/domain/interface/services/IPaymentService.ts b/server/src/domain/interface/services/IPaymentService.ts new file mode 100644 index 00000000..3b90e248 --- /dev/null +++ b/server/src/domain/interface/services/IPaymentService.ts @@ -0,0 +1,12 @@ +export default interface IPaymentService { + createOrder(amount: number, currency: string, receipt: string): Promise; + verifyPaymentSignature(signature: string, orderId: string, paymentId: string): Promise; +} + +export interface RazorpayOrder { + id: string; + amount: string | number; + currency: string; + receipt?: string; + status: string; +} diff --git a/server/src/infrastructure/database/AppointmentModel.ts b/server/src/infrastructure/database/AppointmentModel.ts index 469848b5..06ebf83d 100644 --- a/server/src/infrastructure/database/AppointmentModel.ts +++ b/server/src/infrastructure/database/AppointmentModel.ts @@ -32,7 +32,7 @@ const appointmentSchema = new Schema( }, reason: { type: String, - default: null, + required:true }, notes: { type: String, @@ -44,6 +44,11 @@ const appointmentSchema = new Schema( default: AppointmentStatus.PENDING, required: true, }, + paymentId:{ + type:String, + required:true, + default:null + } }, { timestamps: true, diff --git a/server/src/infrastructure/database/PaymentModel.ts b/server/src/infrastructure/database/PaymentModel.ts new file mode 100644 index 00000000..7ff2724e --- /dev/null +++ b/server/src/infrastructure/database/PaymentModel.ts @@ -0,0 +1,47 @@ +import { model, Schema } from 'mongoose'; +import IPayment, { PaymentStatus } from '../../domain/entities/IPayment'; + +const paymentSchema = new Schema( + { + orderId: { + type: String, + required: true, + }, + paymentId: { + type: String, + default: null, + }, + appointmentId: { + type: Schema.Types.ObjectId, + ref: 'Appointment', + required: true, + index: true, + }, + amount: { + type: Number, + required: true, + }, + currency: { + type: String, + required: true, + }, + status: { + type: String, + enum: Object.values(PaymentStatus), + default: PaymentStatus.PENDING, + required: true, + }, + razorpaySignature: { + type: String, + default: null, + }, + }, + { + timestamps: true, + versionKey: false, + minimize: false, + } +); +const PaymentModel = model('Payment', paymentSchema); + +export default PaymentModel; diff --git a/server/src/infrastructure/repositories/AppointmentRepository.ts b/server/src/infrastructure/repositories/AppointmentRepository.ts index de42ac09..aa698e75 100644 --- a/server/src/infrastructure/repositories/AppointmentRepository.ts +++ b/server/src/infrastructure/repositories/AppointmentRepository.ts @@ -4,8 +4,8 @@ import AppointmentModel from "../database/AppointmentModel"; export default class AppointmentRepository implements IAppointmentRepository { model = AppointmentModel - async create(appointment: IAppointment): Promise { - await this.model.create(appointment) + async create(appointment: IAppointment): Promise { + return (await this.model.create(appointment))._id } async update(appointment: IAppointment): Promise { await this.model.findByIdAndUpdate(appointment._id, appointment, { new: true }) @@ -14,9 +14,9 @@ export default class AppointmentRepository implements IAppointmentRepository { async findOneBySlotId(slotId: string): Promise { return await this.model.findOne({ slotId }); } - + async findManyByDateAndDoctorId(appointmentDate: string, doctorId: string): Promise { - return await this.model.find({appointmentDate,doctorId}); + return await this.model.find({ appointmentDate, doctorId }); } async findByDateAndSlot(appointmentDate: string, slotId: string): Promise { return await this.model.findOne({ appointmentDate, slotId }) @@ -30,4 +30,8 @@ export default class AppointmentRepository implements IAppointmentRepository { await this.model.updateMany({ slotId: { $in: slotIds } }, fields); } + async updateAppointmentStatusToConfirmed(appointmentId: string): Promise { + await this.model.findByIdAndUpdate(appointmentId, { status: AppointmentStatus.PENDING }); + } + } \ No newline at end of file diff --git a/server/src/infrastructure/repositories/PaymentRepository.ts b/server/src/infrastructure/repositories/PaymentRepository.ts new file mode 100644 index 00000000..71c6e85e --- /dev/null +++ b/server/src/infrastructure/repositories/PaymentRepository.ts @@ -0,0 +1,26 @@ +// src/repositories/PaymentRepository.ts + +import IPayment from "../../domain/entities/IPayment"; +import IPaymentRepository from "../../domain/interface/repositories/IPaymentRepository"; +import PaymentModel from "../database/PaymentModel"; + +export default class PaymentRepository implements IPaymentRepository { + model = PaymentModel; + + async create(payment: IPayment): Promise { + return await this.model.create(payment); + } + + async findById(id: string): Promise { + return await this.model.findById(id).exec(); + } + + async findByOrderId(orderId: string): Promise { + return await this.model.findOne({ orderId }).exec(); + } + + async update(payment: IPayment): Promise { + const { _id, ...updateData } = payment; + await this.model.findByIdAndUpdate(_id, updateData, { new: true }).exec(); + } +} diff --git a/server/src/infrastructure/services/RazorPayService.ts b/server/src/infrastructure/services/RazorPayService.ts new file mode 100644 index 00000000..33352498 --- /dev/null +++ b/server/src/infrastructure/services/RazorPayService.ts @@ -0,0 +1,52 @@ +import Razorpay from 'razorpay'; +import IPaymentService, { RazorpayOrder } from '../../domain/interface/services/IPaymentService'; +import { StatusCode } from '../../types'; +import CustomError from '../../domain/entities/CustomError' +import * as crypto from 'crypto'; +import logger from '../../utils/logger'; + + +export default class RazorPayService implements IPaymentService { + private razorpay: Razorpay; + + constructor() { + this.razorpay = new Razorpay({ + key_id: process.env.RAZORPAY_KET_ID!, + key_secret: process.env.RAZORPAY_KEY_SECRET!, + }); + } + + async createOrder(amount: number, currency: string, receipt: string): Promise { + const options = { + amount: amount * 100, + currency, + receipt, + payment_capture: 1, + }; + + try { + const order = await this.razorpay.orders.create(options); + return { + ...order, + amount: typeof order.amount === 'string' ? parseInt(order.amount, 10) : order.amount, + }; + } catch (error) { + logger.error(error) + throw new CustomError('Error creating Razorpay order', StatusCode.PaymentError); + } + } + + async verifyPaymentSignature(signature: string, orderId: string, paymentId: string): Promise { + try { + const generatedSignature = crypto.createHmac('sha256', process.env.RAZORPAY_KEY_SECRET!) + .update(orderId + '|' + paymentId) + .digest('hex'); + + if (generatedSignature !== signature) { + throw new CustomError('Invalid payment signature', StatusCode.PaymentError); + } + } catch (error) { + throw new CustomError('Error verifying payment signature', StatusCode.PaymentError); + } + } +} diff --git a/server/src/presentation/controllers/appointment/AppointmentControllers.ts b/server/src/presentation/controllers/appointment/AppointmentControllers.ts index c212787f..2e0d3181 100644 --- a/server/src/presentation/controllers/appointment/AppointmentControllers.ts +++ b/server/src/presentation/controllers/appointment/AppointmentControllers.ts @@ -11,11 +11,21 @@ export default class AppointmentController { try { const { appointment } = req.body; const patientId = req.patient?.id; - await this.appointmentUseCase.create(appointment, patientId!); - res.status(StatusCode.Success).json({ message: "Appointment created successfully" }); + const { appointmentId, orderId } = await this.appointmentUseCase.createAppointment(appointment, patientId!); + res.status(StatusCode.Success).json({ orderId, appointmentId }); } catch (error: any) { next(error); } } + async completePayment(req: CustomRequest, res: Response, next: NextFunction) { + try { + const { razorpay_order_id, razorpay_payment_id, razorpay_signature, appointmentId } = req.body; + await this.appointmentUseCase.verifyPayment({ razorpay_order_id, razorpay_payment_id, razorpay_signature }, appointmentId) + res.status(StatusCode.Success).json({ message: "Payment Verification Completed" }); + } catch (error) { + next(error) + } + } + } diff --git a/server/src/presentation/routers/appointment/AppointmentRoutes.ts b/server/src/presentation/routers/appointment/AppointmentRoutes.ts index 4b5c5b74..91f8a927 100644 --- a/server/src/presentation/routers/appointment/AppointmentRoutes.ts +++ b/server/src/presentation/routers/appointment/AppointmentRoutes.ts @@ -6,21 +6,26 @@ import AppointmentController from '../../controllers/appointment/AppointmentCont import PatientAuthMiddleware from '../../middlewares/PatientAuthMiddleware'; import JWTService from '../../../infrastructure/services/JWTService'; import JoiService from '../../../infrastructure/services/JoiService'; +import RazorPayService from '../../../infrastructure/services/RazorPayService'; +import PaymentRepository from '../../../infrastructure/repositories/PaymentRepository'; const router = express.Router(); const appointmentRepository = new AppointmentRepository(); const slotRepository = new SlotRepository(); -const tokenService = new JWTService() -const validatorService = new JoiService() +const tokenService = new JWTService(); +const validatorService = new JoiService(); +const paymentService = new RazorPayService(); +const paymentRepository = new PaymentRepository() -const appointmentUseCase = new AppointmentUseCase(appointmentRepository, slotRepository, validatorService); +const appointmentUseCase = new AppointmentUseCase(appointmentRepository, slotRepository, validatorService, paymentService, paymentRepository); const appointmentController = new AppointmentController(appointmentUseCase); const authorizePatient = new PatientAuthMiddleware(tokenService); router.post('/', authorizePatient.exec.bind(authorizePatient), appointmentController.create.bind(appointmentController)); +router.put('/payment', authorizePatient.exec.bind(authorizePatient), appointmentController.completePayment.bind(appointmentController)) export default router; \ No newline at end of file diff --git a/server/src/types/index.ts b/server/src/types/index.ts index a55c4c24..3e4507cd 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -23,6 +23,7 @@ export enum StatusCode { NoContent = 204, BadRequest = 400, Unauthorized = 401, + PaymentError=402, Forbidden = 403, NotFound = 404, Conflict = 409, diff --git a/server/src/use_case/appointment/AppointmentUseCase.ts b/server/src/use_case/appointment/AppointmentUseCase.ts index fadbf969..9e0c5c21 100644 --- a/server/src/use_case/appointment/AppointmentUseCase.ts +++ b/server/src/use_case/appointment/AppointmentUseCase.ts @@ -4,44 +4,104 @@ import IAppointmentRepository from "../../domain/interface/repositories/IAppoint import ISlotRepository from "../../domain/interface/repositories/ISlotRepository"; import IValidatorService from "../../domain/interface/services/IValidatorService"; import { StatusCode } from "../../types"; +import IPaymentService from "../../domain/interface/services/IPaymentService"; +import IPaymentRepository from "../../domain/interface/repositories/IPaymentRepository"; +import { PaymentStatus } from "../../domain/entities/IPayment"; export default class AppointmentUseCase { + bookingAmount: number; + constructor( - private appointRepository: IAppointmentRepository, + private appointmentRepository: IAppointmentRepository, private slotRepository: ISlotRepository, - private validatorService: IValidatorService - ) { } + private validatorService: IValidatorService, + private paymentService: IPaymentService, + private paymentRepository: IPaymentRepository + ) { + this.bookingAmount = 300; + } - async create({ slotId, appointmentDate, appointmentType, reason, doctorId, notes }: IAppointment, patientId: string): Promise { - this.validatorService.validateRequiredFields({ slotId, appointmentDate, appointmentType, reason, doctorId }); - this.validatorService.validateIdFormat(doctorId!); - this.validatorService.validateIdFormat(slotId!); - this.validatorService.validateIdFormat(patientId!) - this.validatorService.validateEnum(appointmentType!, Object.values(AppointmentType)); - this.validatorService.validateDateFormat(appointmentDate!); - this.validatorService.validateLength(reason!, 1, 255); - if (notes) this.validatorService.validateLength(notes, 0, 255); + async createAppointment( + appointmentData: IAppointment, + patientId: string + ): Promise<{ appointmentId: string, orderId: string }> { + this.validateAppointmentData(appointmentData, patientId); - const slot = await this.slotRepository.findById(slotId!); + const slot = await this.slotRepository.findById(appointmentData.slotId!); if (!slot) throw new CustomError("Slot Not Found", StatusCode.NotFound); - + if (slot.status === 'booked') { - const bookedAppointment = await this.appointRepository.findByDateAndSlot(appointmentDate!, slotId!); + const bookedAppointment = await this.appointmentRepository.findByDateAndSlot(appointmentData.appointmentDate!, appointmentData.slotId!); if (bookedAppointment) throw new CustomError("Slot already booked", StatusCode.Conflict); } else { - slot!.status = 'booked'; + slot.status = 'booked'; + await this.slotRepository.update(slot); } - await this.slotRepository.update(slot); - await this.appointRepository.create({ - slotId, + + const payment = await this.paymentRepository.create({ + orderId: '', + appointmentId: appointmentData._id!, + amount: this.bookingAmount, + currency: 'INR', + status: PaymentStatus.PENDING, + }); + + const razorpayOrder = await this.paymentService.createOrder(this.bookingAmount, 'INR', `receipt_${payment._id}`); + + await this.paymentRepository.update({ + _id: payment._id, + orderId: razorpayOrder.id!, + }); + + const appointmentId = await this.appointmentRepository.create({ + ...appointmentData, patientId, - status: AppointmentStatus.PENDING, - notes, - appointmentDate, - appointmentType, - reason, - doctorId + status: AppointmentStatus.PAYMENT_PENDING, + paymentId: razorpayOrder.id!, }); + + return { orderId: razorpayOrder.id, appointmentId }; } -} \ No newline at end of file + async verifyPayment(paymentData: { razorpay_order_id: string, razorpay_payment_id: string, razorpay_signature: string }, appointmentId: string): Promise { + this.validatorService.validateRequiredFields({ + razorpay_order_id: paymentData.razorpay_order_id, + razorpay_payment_id: paymentData.razorpay_payment_id, + razorpay_signature: paymentData.razorpay_signature, + appointmentId, + + }); + await this.paymentService.verifyPaymentSignature( + paymentData.razorpay_signature, + paymentData.razorpay_order_id, + paymentData.razorpay_payment_id + ); + const payment = await this.paymentRepository.findByOrderId(paymentData.razorpay_order_id); + if (!payment) throw new CustomError("Payment not found", StatusCode.NotFound); + + await this.paymentRepository.update({ + _id: payment._id, + orderId: payment.orderId, + paymentId: paymentData.razorpay_payment_id, + appointmentId: payment.appointmentId, + amount: payment.amount, + currency: payment.currency, + status: PaymentStatus.COMPLETED, + razorpaySignature: paymentData.razorpay_signature, + }); + + await this.appointmentRepository.updateAppointmentStatusToConfirmed(appointmentId); + } + + private validateAppointmentData({ appointmentDate, appointmentType, doctorId, notes, reason, slotId, }: IAppointment, patientId: string): void { + this.validatorService.validateRequiredFields({ slotId, appointmentType, doctorId, reason, appointmentDate })! + this.validatorService.validateIdFormat(doctorId!); + this.validatorService.validateIdFormat(slotId!); + this.validatorService.validateIdFormat(patientId!); + this.validatorService.validateEnum(appointmentType!, Object.values(AppointmentType)); + this.validatorService.validateDateFormat(appointmentDate!); + this.validatorService.validateLength(reason!, 1, 255); + + if (notes) this.validatorService.validateLength(notes, 0, 255); + } +}