Medicare Razorpay Payment Gateway Integration

Business Scenario

In this lab, you will enhance the Medicare application by integrating the Razorpay Payment Gateway to enable patients to make secure online payments for their doctor appointments.

Pre-Lab Preparation

Module:

1) React Basics & Backend Integration (Spring Boot)​

Phase 13 — Final Flow

Patient books appointment
        ↓
PENDING + UNPAID
        ↓
Pay Now
        ↓
Spring Boot creates Razorpay Order
        ↓
Razorpay Checkout opens
        ↓
Payment succeeds
        ↓
React receives:
payment_id
order_id
signature
        ↓
React sends ONLY:
payment_id + signature
        ↓
Spring Boot uses order ID from DATABASE
        ↓
Verify signature
        ↓
Fetch payment from Razorpay
        ↓
Check amount + order + captured status
        ↓
CONFIRMED + PAID
  • We are not creating:
Payment entity 
PaymentRepository 
PaymentVerificationResponse DTO 
  • We reuse: AppointmentResponse

Add Razorpay dependency

1

  • In pom.xml, inside <dependencies>:
<!-- Razorpay Payment Gateway -->
<dependency>
    <groupId>com.razorpay</groupId>
    <artifactId>razorpay-java</artifactId>
    <version>1.4.10</version>
</dependency>
  • Then Maven → Update Project.

Add Razorpay dependency

2

spring.application.name=medicare-backend


# -------------------------------------------
# DATABASE
# -------------------------------------------
spring.datasource.url=jdbc:mysql://localhost:3306/medicare_db

spring.datasource.username=${DB_USERNAME:root}

spring.datasource.password=${DB_PASSWORD}


# -------------------------------------------
# JPA / HIBERNATE
# -------------------------------------------

spring.jpa.hibernate.ddl-auto=update

spring.jpa.show-sql=true

spring.jpa.properties.hibernate.format_sql=true


# -------------------------------------------
# JWT
# -------------------------------------------

jwt.secret=${JWT_SECRET}

jwt.expiration=3600000


# -------------------------------------------
# RAZORPAY
# -------------------------------------------
razorpay.key-id=${RAZORPAY_KEY_ID}

razorpay.key-secret=${RAZORPAY_KEY_SECRET}
  • In STS Environment Variables:
DB_PASSWORD
JWT_SECRET
RAZORPAY_KEY_ID
RAZORPAY_KEY_SECRET
  • Use Razorpay Test Mode keys for now.
  • Never put RAZORPAY_KEY_SECRET in React.

Add Razorpay dependency

3

  • Replace the complete file:
package com.medicare.entity;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;


@Entity
@Table(name = "appointments")
public class Appointment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @ManyToOne
    @JoinColumn(name = "patient_id", nullable = false)
    private User patient;

    @ManyToOne
    @JoinColumn(name = "doctor_id", nullable = false)
    private DoctorProfile doctor;

    @Column(name = "appointment_date", nullable = false)
    private LocalDate appointmentDate;

    @Enumerated(EnumType.STRING)
    @Column(name = "time_slot", nullable = false)
    private TimeSlot timeSlot;

    @Column(name = "consultation_fee", nullable = false, precision = 10, scale = 2)
    private BigDecimal consultationFee;

    @Enumerated(EnumType.STRING)
    @Column(name = "appointment_status", nullable = false)
    private AppointmentStatus appointmentStatus = AppointmentStatus.PENDING;

    @Enumerated(EnumType.STRING)
    @Column(name = "payment_status", nullable = false)
    private PaymentStatus paymentStatus = PaymentStatus.UNPAID;

    // Razorpay order created before payment
    @Column(name = "razorpay_order_id", unique = true, length = 100)
    private String razorpayOrderId;
    // Razorpay payment received after success
    @Column(name = "razorpay_payment_id", unique = true, length = 100)
    private String razorpayPaymentId;

    @Column(name = "paid_at")
    private LocalDateTime paidAt;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt = LocalDateTime.now();


    public Appointment() {
    }

    public Long getId() {
        return id;
    }

    public User getPatient() {
        return patient;
    }

    public void setPatient(User patient) {
        this.patient = patient;
    }

    public DoctorProfile getDoctor() {
        return doctor;
    }
    public void setDoctor(DoctorProfile doctor) {
        this.doctor = doctor;
    }

    public LocalDate getAppointmentDate() {
        return appointmentDate;
    }

    public void setAppointmentDate(LocalDate appointmentDate) {
        this.appointmentDate = appointmentDate;
    }

    public TimeSlot getTimeSlot() {
        return timeSlot;
    }

    public void setTimeSlot(TimeSlot timeSlot) {
        this.timeSlot = timeSlot;
    }

    public BigDecimal getConsultationFee() {
        return consultationFee;
    }

    public void setConsultationFee(BigDecimal consultationFee) {
        this.consultationFee = consultationFee;
    }

    public AppointmentStatus getAppointmentStatus() {
        return appointmentStatus;
    }

    public void setAppointmentStatus(AppointmentStatus appointmentStatus) {
        this.appointmentStatus = appointmentStatus;
    }

    public PaymentStatus getPaymentStatus() {
        return paymentStatus;
    }

    public void setPaymentStatus(PaymentStatus paymentStatus) {
        this.paymentStatus = paymentStatus;
    }

    public String getRazorpayOrderId() {
        return razorpayOrderId;
    }

    public void setRazorpayOrderId(String razorpayOrderId) {
        this.razorpayOrderId = razorpayOrderId;
    }

    public String getRazorpayPaymentId() {
        return razorpayPaymentId;
    }

    public void setRazorpayPaymentId(String razorpayPaymentId) {
        this.razorpayPaymentId = razorpayPaymentId;
    }
    public LocalDateTime getPaidAt() {
        return paidAt;
    }

    public void setPaidAt(LocalDateTime paidAt) {
        this.paidAt = paidAt;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }
}
  • We intentionally do not store the Razorpay signature.

DTO 1 — PaymentOrderResponse.java

4

  • Create :
dto/PaymentOrderResponse.java
package com.medicare.dto;

public class PaymentOrderResponse {
    private Long appointmentId;
    private String razorpayOrderId;
    private long amount;
    private String currency;
    private String keyId;


    public PaymentOrderResponse(Long appointmentId, String razorpayOrderId, 
                                long amount, String currency, String keyId) {
        this.appointmentId = appointmentId;
        this.razorpayOrderId = razorpayOrderId;
        this.amount = amount;
        this.currency = currency;
        this.keyId = keyId;
    }


    public Long getAppointmentId() {
        return appointmentId;
    }

    public String getRazorpayOrderId() {
        return razorpayOrderId;
    }
    public long getAmount() {
        return amount;
    }

    public String getCurrency() {
        return currency;
    }

    public String getKeyId() {
        return keyId;
    }
}

DTO 2 — PaymentVerificationRequest.java

5

  • Create :
dto/PaymentVerificationRequest.java
package com.medicare.dto;
import jakarta.validation.constraints.NotBlank;
public class PaymentVerificationRequest {

    @NotBlank(message = "Razorpay payment ID is required")
    private String razorpayPaymentId;

    @NotBlank(message = "Razorpay signature is required")
    private String razorpaySignature;


    public PaymentVerificationRequest() {
    }

    public String getRazorpayPaymentId() {
        return razorpayPaymentId;
    }

    public void setRazorpayPaymentId(String razorpayPaymentId) {
        this.razorpayPaymentId = razorpayPaymentId;
    }

    public String getRazorpaySignature() {
        return razorpaySignature;
    }

    public void setRazorpaySignature(String razorpaySignature) {
        this.razorpaySignature = razorpaySignature;
    }
}

Create PaymentException.java

6

  • That's all for payment DTOs.
package com.medicare.exception;

public class PaymentException
        extends RuntimeException {

    public PaymentException(
            String message) {
        super(message);
    }
}

Add PaymentException handler

7

  • Inside GlobalExceptionHandler, before the final }:
@ExceptionHandler(PaymentException.class)
public ResponseEntity<ErrorResponse> handlePaymentException(
      PaymentException exception, HttpServletRequest request) {

    ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_GATEWAY.value(),
            exception.getMessage(),
            request.getRequestURI()
    );

    return ResponseEntity
            .status(HttpStatus.BAD_GATEWAY)
            .body(errorResponse);
}

Create PaymentService.java

8

  • Create :
service/PaymentService.java
package com.medicare.service;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.json.JSONObject;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.medicare.dto.AppointmentResponse;
import com.medicare.dto.PaymentOrderResponse;
import com.medicare.dto.PaymentVerificationRequest;

import com.medicare.entity.Appointment;
import com.medicare.entity.AppointmentStatus;
import com.medicare.entity.PaymentStatus;
import com.medicare.entity.User;
import com.medicare.exception.AppointmentNotFoundException;
import com.medicare.exception.PaymentException;
import com.medicare.exception.UserNotFoundException;
import com.medicare.repository.AppointmentRepository;
import com.medicare.repository.UserRepository;

import com.razorpay.Order;
import com.razorpay.Payment;
import com.razorpay.RazorpayClient;
import com.razorpay.RazorpayException;
import com.razorpay.Utils;


@Service
public class PaymentService {

    private final AppointmentRepository appointmentRepository;
    private final UserRepository userRepository;
    private final AppointmentService appointmentService;

    @Value("${razorpay.key-id}")
    private String keyId;

    @Value("${razorpay.key-secret}")
    private String keySecret;


    public PaymentService(AppointmentRepository appointmentRepository, 
           UserRepository userRepository, AppointmentService appointmentService) {
        this.appointmentRepository = appointmentRepository;
        this.userRepository = userRepository;
        this.appointmentService = appointmentService;
    }
    // -------------------------------------------------
    // CREATE RAZORPAY ORDER
    // -------------------------------------------------

    @Transactional
    @PreAuthorize("hasRole('PATIENT')")
    public PaymentOrderResponse createOrder(Long appointmentId, String email) {

        User patient = findUserByEmail(email);

        Appointment appointment = findAppointmentById(appointmentId);

        verifyOwnership(appointment, patient);

        validateForPayment(appointment);

        long amount = convertToPaise(appointment.getConsultationFee());


        /*
         * If an order already exists,
         * reuse it for payment retry.
         */
        if (appointment.getRazorpayOrderId() != 
                 null && !appointment.getRazorpayOrderId().isBlank()) {

            return new PaymentOrderResponse(
                    appointment.getId(),
                    appointment.getRazorpayOrderId(),
                    amount,"INR",keyId
            );
        }


        try {

            RazorpayClient client = new RazorpayClient(keyId, keySecret);

            JSONObject orderRequest = new JSONObject();

            orderRequest.put("amount", amount);
            orderRequest.put("currency", "INR");
            orderRequest.put("receipt", "appt_" + appointment.getId());

            JSONObject notes = new JSONObject();
            notes.put("appointmentId", appointment.getId());

            orderRequest.put("notes", notes);

            Order order = client.orders.create(orderRequest);

            String razorpayOrderId = order.get("id");

            appointment.setRazorpayOrderId(razorpayOrderId);

            appointmentRepository.save(appointment);

            return new PaymentOrderResponse(
                    appointment.getId(), razorpayOrderId, amount,"INR",keyId
            );
        } catch (RazorpayException exception) {
            throw new PaymentException(
                    "Unable to create payment order: " + getRazorpayMessage(exception)
            );
        }
    }

    // -------------------------------------------------
    // VERIFY PAYMENT
    // -------------------------------------------------

    @Transactional
    @PreAuthorize("hasRole('PATIENT')")
    public AppointmentResponse verifyPayment(Long appointmentId, 
       PaymentVerificationRequest request, String email) {

        User patient = findUserByEmail(email);

        Appointment appointment = findAppointmentById(appointmentId);

        verifyOwnership(appointment, patient);


        /*
         * Makes verification safe
         * if it is accidentally retried.
         */
        if (appointment.getPaymentStatus() == PaymentStatus.PAID) {
            return appointmentService.getAppointmentById(appointmentId, email);
        }
        if (appointment.getAppointmentStatus() != AppointmentStatus.PENDING) {
            throw new IllegalArgumentException(
            "Only pending appointments can be paid");
        }

        if (appointment.getPaymentStatus() != PaymentStatus.UNPAID) {
            throw new IllegalArgumentException(
            "Appointment is not available for payment");
        }

        if (appointment.getRazorpayOrderId() == 
           null || appointment.getRazorpayOrderId().isBlank()) {
            throw new IllegalArgumentException(
            "Payment order has not been created");
        }


        try {

            // -----------------------------------------
            // 1. VERIFY RAZORPAY SIGNATURE
            // -----------------------------------------

            JSONObject attributes = new JSONObject();

            /*
             * IMPORTANT:
             * Use order ID stored in database.
             * Do not trust the browser order ID.
             */
            attributes.put("razorpay_order_id", appointment.getRazorpayOrderId());
            attributes.put("razorpay_payment_id", request.getRazorpayPaymentId());
            attributes.put("razorpay_signature", request.getRazorpaySignature());

            boolean validSignature = 
                    Utils.verifyPaymentSignature(attributes, keySecret);


            if (!validSignature) {

                throw new IllegalArgumentException("Invalid payment signature");
            }


            // -----------------------------------------
            // 2. FETCH PAYMENT DIRECTLY FROM RAZORPAY
            // -----------------------------------------

            RazorpayClient client = new RazorpayClient(keyId, keySecret);

            Payment payment = client.payments.fetch(request.getRazorpayPaymentId());

            String paymentOrderId = payment.get("order_id");
            Number paymentAmount = payment.get("amount");
            String paymentCurrency = payment.get("currency");
            String razorpayStatus = payment.get("status");

            long expectedAmount = convertToPaise(appointment.getConsultationFee());
            // -----------------------------------------
            // 3. VERIFY ORDER
            // -----------------------------------------

            if (!appointment.getRazorpayOrderId().equals(paymentOrderId)) {

                throw new IllegalArgumentException(
                "Payment order does not match appointment");
            }


            // -----------------------------------------
            // 4. VERIFY AMOUNT
            // -----------------------------------------

            if (paymentAmount ==
               null || paymentAmount.longValue() != expectedAmount) {

                throw new IllegalArgumentException(
                "Payment amount does not match consultation fee");
            }

            // -----------------------------------------
            // 5. VERIFY CURRENCY
            // -----------------------------------------

            if (!"INR".equalsIgnoreCase(paymentCurrency)) {
                throw new IllegalArgumentException("Invalid payment currency");
            }
            // -----------------------------------------
            // 6. VERIFY PAYMENT CAPTURED
            // -----------------------------------------

            if (!"captured".equalsIgnoreCase(razorpayStatus)) {

                throw new IllegalArgumentException("Payment has not been captured");
            }


            // -----------------------------------------
            // 7. UPDATE APPOINTMENT
            // -----------------------------------------

            appointment.setRazorpayPaymentId(request.getRazorpayPaymentId());
            appointment.setPaymentStatus(PaymentStatus.PAID);
            appointment.setAppointmentStatus(AppointmentStatus.CONFIRMED);
            appointment.setPaidAt(LocalDateTime.now());

            appointmentRepository.save(appointment);


            /*
             * Reuse our existing DTO.
             * No PaymentVerificationResponse DTO.
             */
            return appointmentService.getAppointmentById(appointmentId, email);

        } catch (RazorpayException exception) {
            throw new PaymentException(
                    "Unable to verify payment: " + getRazorpayMessage(exception)
            );
        }
    }


    // -------------------------------------------------
    // HELPERS
    // -------------------------------------------------

    private User findUserByEmail(String email) {

        return userRepository
                .findByEmailIgnoreCase(email)
                .orElseThrow(() -> new UserNotFoundException("User not found"));
    }


    private Appointment findAppointmentById(Long appointmentId) {

        return appointmentRepository
                .findById(appointmentId)
                .orElseThrow(() -> new AppointmentNotFoundException(
                "Appointment not found with id: " + appointmentId));
    }


    private void verifyOwnership(Appointment appointment, User patient) {

        if (!appointment.getPatient().getId().equals(patient.getId())) {
            throw new AccessDeniedException(
            "You cannot make payment for this appointment");
        }
    }


    private void validateForPayment(Appointment appointment) {

        if (appointment.getAppointmentStatus() != AppointmentStatus.PENDING) {

            throw new IllegalArgumentException(
            "Only pending appointments can be paid");
        }


        if (appointment.getPaymentStatus() != PaymentStatus.UNPAID) {

            throw new IllegalArgumentException(
            "Appointment is already paid or unavailable for payment");
        }
    }


    private long convertToPaise(BigDecimal amount) {

        return amount.movePointRight(2).longValueExact();
    }

    private String getRazorpayMessage(RazorpayException exception) {
        String description = exception.getDescription();
        if (description != null && !description.isBlank()) {
            return description;
        }

        return exception.getMessage();
    }
}
  • The Java SDK supports payments.fetch(...), and payment signature verification is provided by Utils.verifyPaymentSignature(...). ​

Create PaymentController.java

9

package com.medicare.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.medicare.dto.AppointmentResponse;
import com.medicare.dto.PaymentOrderResponse;
import com.medicare.dto.PaymentVerificationRequest;

import com.medicare.service.PaymentService;

import jakarta.validation.Valid;


@RestController
@RequestMapping("/api/payments")
public class PaymentController {


    private final PaymentService paymentService;


    public PaymentController(
            PaymentService paymentService) {

        this.paymentService = paymentService;
    }


    // ---------------------------------------------
    // CREATE RAZORPAY ORDER
    // ---------------------------------------------
    @PostMapping("/{appointmentId}/order")
    public ResponseEntity<PaymentOrderResponse>
    createOrder(
            @PathVariable Long appointmentId,
            Authentication authentication) {


        return ResponseEntity.ok(
           paymentService.createOrder(appointmentId,authentication.getName())
        );
    }


    // ---------------------------------------------
    // VERIFY RAZORPAY PAYMENT
    // ---------------------------------------------

    @PostMapping("/{appointmentId}/verify")
    public ResponseEntity<AppointmentResponse>
    verifyPayment(
            @PathVariable Long appointmentId,
            @Valid
            @RequestBody
            PaymentVerificationRequest request,

            Authentication authentication) {

        return ResponseEntity.ok(
                paymentService.verifyPayment(
                          appointmentId,request,authentication.getName())
        );
    }
}

Update SecurityConfig

10

  • Before:
.anyRequest()
.denyAll()
  • add:
// Patient payment APIs
.requestMatchers( HttpMethod.POST,"/api/payments/**")
.hasRole("PATIENT")
  • So the ending should look like:
// Patient or assigned doctor can view
// prescription for an appointment
.requestMatchers(HttpMethod.GET,"/api/prescriptions/appointment/*")
.hasAnyRole("PATIENT","DOCTOR")

// Patient payment APIs
.requestMatchers(HttpMethod.POST,"/api/payments/**")
.hasRole("PATIENT")
.anyRequest()
.denyAll()

Tighten appointment cancellation

11

  • Now that real payment exists, we should prevent cancelling a paid appointment because we have not implemented refunds
  • In AppointmentService, replace your cancelAppointment() with:
@PreAuthorize("hasRole('PATIENT')")
public AppointmentResponse
cancelAppointment(Long appointmentId,   String email) {


    User patient = findUserByEmail(email);

    Appointment appointment = findAppointmentById(appointmentId);


    if (!appointment.getPatient().getId().equals(patient.getId())) {
        throw new AccessDeniedException(
                "You cannot cancel this appointment"
        );
    }


    if (appointment.getAppointmentStatus()==AppointmentStatus.COMPLETED) {
        throw new IllegalArgumentException(
                "Completed appointment cannot be cancelled"
        );
    }
    if (appointment.getAppointmentStatus() == AppointmentStatus.CANCELLED) {
        throw new IllegalArgumentException(
                "Appointment is already cancelled"
        );
    }

    /*
     * Refund flow is not part
     * of this project.
     */
    if (appointment.getPaymentStatus() == PaymentStatus.PAID) {
        throw new IllegalArgumentException(
                "Paid appointment cannot be cancelled"
        );
    }

    appointment.setAppointmentStatus(AppointmentStatus.CANCELLED);
    return mapToAppointmentResponse(appointmentRepository.save(appointment));
}

Tighten doctor completion

12

  • Replace completeAppointment() with:
@PreAuthorize("hasRole('DOCTOR')")
public AppointmentResponse completeAppointment(Long appointmentId, String email) {

    User user = findUserByEmail(email);

    DoctorProfile doctor = findDoctorByUserId(user.getId());

    Appointment appointment = findAppointmentById(appointmentId);


    if (!appointment.getDoctor().getId().equals(doctor.getId())) {
        throw new AccessDeniedException("You cannot complete this appointment");
    }

    if (appointment.getAppointmentStatus() == AppointmentStatus.CANCELLED) {
        throw new IllegalArgumentException(
           "Cancelled appointment cannot be completed");
    }

    if (appointment.getAppointmentStatus() == AppointmentStatus.COMPLETED) {
        throw new IllegalArgumentException("Appointment is already completed");
    }

    /*
     * After Phase 13, consultation can be completed only after successful payment.
     */
    if (appointment.getAppointmentStatus() != AppointmentStatus.CONFIRMED || 
                            appointment.getPaymentStatus() != PaymentStatus.PAID) {
        throw new IllegalArgumentException(
        "Only confirmed and paid appointments can be completed");
    }

    appointment.setAppointmentStatus(AppointmentStatus.COMPLETED);
    return mapToAppointmentResponse(appointmentRepository.save(appointment));
}

FRONTEND

  • Now we connect Razorpay Checkout.
  • Razorpay's Standard Checkout uses checkout.js; the handler receives the payment ID, order ID and signature, and the signature must then be verified by your server. ​

Final index.html

13

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title> Medicare Healthcare </title>

</head>

<body>
    <div id="root"></div>

    <!-- Razorpay Standard Checkout -->
    <script src="https://checkout.razorpay.com/v1/checkout.js"> </script>
    <script type="module" src="/src/main.jsx">  </script>

</body>

</html>
  • Replace :
  • No npm Razorpay package is required for the web Standard Checkout approach.

Create paymentService.js

14

src/services/paymentService.js
  • Create :
import api, { getAuthConfig } from "../api/api";

const createOrder = async (appointmentId) => {

    const response = await api.post(`/payments/${appointmentId}/order`,
        {},
        getAuthConfig()
    );
    return response.data;
};


const verifyPayment = async (appointmentId, paymentData) => {
const verifyPayment = async (appointmentId, paymentData) => {

    const response = await api.post(`/payments/${appointmentId}/verify`,
        paymentData,
        getAuthConfig()
    );

    return response.data;
};


const paymentService = { createOrder, verifyPayment};

export default paymentService;

Create reusable PaymentButton.jsx

15

src/components/payment/PaymentButton.jsx
  • Create :
import { useState } from "react";
import { useAuth } from "../../context/AuthContext";
import paymentService from "../../services/paymentService";


function PaymentButton({ appointment, onPaymentSuccess, 
                       className = "btn btn-success w-100" }) {

    const { user } = useAuth();
    const [paying, setPaying] = useState(false);
    const [error, setError] = useState("");

    const handlePayment = async () => {

        setError("");

        if (!window.Razorpay) {
            setError("Razorpay Checkout could not be loaded.");
            return;
        }

        try {
            setPaying(true);

            // ----------------------------------
            // CREATE ORDER ON BACKEND
            // ----------------------------------
            const order = await paymentService.createOrder(appointment.id);
            let verificationStarted = false;

            const options = {
                key: order.keyId,
                amount: order.amount,
                currency: order.currency,
                name: "Medicare Healthcare",
                description: `Consultation with ${appointment.doctorName}`,
                order_id: order.razorpayOrderId,


                // ----------------------------------
                // PAYMENT SUCCESS
                // ----------------------------------

                handler: async (response) => {
                    verificationStarted = true;
                    setError("");


                    try {
                        const updatedAppointment = 
                               await paymentService.verifyPayment(
                            appointment.id,
                            {
                                razorpayPaymentId: response.razorpay_payment_id,
                                razorpaySignature: response.razorpay_signature
                            }
                        );


                        if (onPaymentSuccess) {
                            onPaymentSuccess(updatedAppointment);
                        }

                    } catch (error) {

                        setError(
                            error.response?.data?.message ||
                            "Payment verification failed"
                        );

                    } finally {
                        setPaying(false);
                    }
                },


                // ----------------------------------
                // PREFILL
                // ----------------------------------

                prefill: {
                    name: user?.name || "",
                    email: user?.email || ""
                },


                // ----------------------------------
                // MODAL SETTINGS
                // ----------------------------------

                modal: {
                    confirm_close: true,

                    ondismiss: () => {

                        if (!verificationStarted) {
                            setPaying(false);
                        }
                    }
                }
            };


            const razorpay = new window.Razorpay(options);


            razorpay.on("payment.failed", (response) => {

                setError(
                    response.error?.description ||
                    "Payment failed. Please try again."
                );
            });


            razorpay.open();


        } catch (error) {

            setPaying(false);

            setError(
                error.response?.data?.message ||
                "Unable to start payment"
            );
        }
    };


    return (
        <div>

            <button type="button" className={className} onClick={handlePayment} disabled={paying}>
                {
                    paying
                        ? "Processing Payment..."
                        : "Pay Now"
                }
            </button>


            {
                error && (
                    <div className="alert alert-danger mt-2 mb-0">
                        {error}
                    </div>
                )
            }

        </div>
    );
}


export default PaymentButton;
                        if (onPaymentSuccess) {
                            onPaymentSuccess(updatedAppointment);
                        }

                    } catch (error) {

                        setError(
                            error.response?.data?.message ||
                            "Payment verification failed"
                        );

                    } finally {
                        setPaying(false);
                    }
                },


                // ----------------------------------
                // PREFILL
                // ----------------------------------

                prefill: {
                    name: user?.name || "",
                    email: user?.email || ""
                },


                // ----------------------------------
                // MODAL SETTINGS
                // ----------------------------------
                modal: {
                    confirm_close: true,

                    ondismiss: () => {

                        if (!verificationStarted) {
                            setPaying(false);
                        }
                    }
                }
            };


            const razorpay = new window.Razorpay(options);


            razorpay.on("payment.failed", (response) => {

                setError(
                    response.error?.description ||
                    "Payment failed. Please try again."
                );
            });


            razorpay.open();

        } catch (error) {
            setPaying(false);
            setError(
                error.response?.data?.message ||
                "Unable to start payment"
            );
        }
    };


    return (
        <div>

            <button type="button" className={className} 
                   onClick={handlePayment} disabled={paying}>
                {
                    paying
                        ? "Processing Payment..."
                        : "Pay Now"
                }
            </button>

            {
                error && (
                    <div className="alert alert-danger mt-2 mb-0">
                        {error}
                    </div>
                )
            }

        </div>
    );
}

export default PaymentButton;
  • The modal.ondismiss option is supported by Razorpay Checkout and lets us reset the button if the user closes Checkout without paying.

Final AppointmentCard.jsx

16

  • Replace it with :
import PaymentButton from "../payment/PaymentButton";

function AppointmentCard({ appointment, onCancel, cancellingId, 
                          onViewPrescription, onPaymentSuccess }) {

    const formatTimeSlot = (timeSlot) => {

        switch (timeSlot) {
            case "MORNING_10_AM":
                return "10:00 AM";

            case "AFTERNOON_2_PM":
                return "2:00 PM";

            case "EVENING_5_PM":
                return "5:00 PM";

            default:
                return timeSlot;
        }
    };

    const getAppointmentBadge = (status) => {

        switch (status) {

            case "PENDING":
                return "text-bg-warning";

            case "CONFIRMED":
                return "text-bg-primary";

            case "COMPLETED":
                return "text-bg-success";

            case "CANCELLED":
                return "text-bg-secondary";
            default:
                return "text-bg-light";
        }
    };


    const getPaymentBadge = (status) => {

        switch (status) {

            case "PAID":
                return "text-bg-success";

            case "FAILED":
                return "text-bg-danger";

            case "UNPAID":
                return "text-bg-warning";

            default:
                return "text-bg-light";
        }
    };


    const canPay =
        appointment.appointmentStatus === "PENDING" &&
        appointment.paymentStatus === "UNPAID";
    /*
     * Refunds are not part
     * of the project.
     *
     * So paid appointments
     * cannot be cancelled.
     */
    const canCancel = canPay;


    return (
        <div className="card shadow-sm h-100">

            <div className="card-body">

                <div 
                className="d-flex justify-content-between align-items-start mb-3">

                    <div>

                        <h5 className="mb-1">
                            {appointment.doctorName}
                        </h5>

                        <p className="text-primary mb-0">
                            {appointment.specialization}
                        </p>

                    </div>
                    <span className={`badge ${getAppointmentBadge(
                                     appointment.appointmentStatus)}`}>
                        {appointment.appointmentStatus}
                    </span>

                </div>


                <hr />


                <p className="mb-2">
                    <strong>Appointment ID:</strong>{" "}
                    {appointment.id}
                </p>


                <p className="mb-2">
                    <strong>Date:</strong>{" "}
                    {appointment.appointmentDate}
                </p>


                <p className="mb-2">
                    <strong>Time:</strong>{" "}
                    {formatTimeSlot(appointment.timeSlot)}
                </p>


                <p className="mb-2">
                    <strong>Consultation Fee:</strong>{" "}
                    ₹{appointment.consultationFee}
                </p>


                <p className="mb-4">
                    <strong>Payment:</strong>{" "}

                    <span className={`badge ${getPaymentBadge(
                                     appointment.paymentStatus)}`}>
                        {appointment.paymentStatus}
                    </span>
                </p>


                {/* PAYMENT + CANCEL */}

                {
                    canPay && (
                        <div className="d-grid gap-2">

                            <PaymentButton appointment={appointment} 
                               onPaymentSuccess={onPaymentSuccess} />


                            <button
                                type="button"
                                className="btn btn-outline-danger"
                                onClick={() => onCancel(appointment.id)}
                                disabled={cancellingId === appointment.id}>
                                {
                                    cancellingId === appointment.id
                                        ? "Cancelling..."
                                        : "Cancel Appointment"
                                }
                            </button>

                        </div>
                    )
                }


                {/* CONFIRMED */}

                {
                    appointment.appointmentStatus === "CONFIRMED" &&
                    appointment.paymentStatus === "PAID" && (
                        <div className="alert alert-success mb-0">
                            Payment completed.
                            Appointment confirmed.
                        </div>
                    )
                }


                {/* VIEW PRESCRIPTION */}

                {
                    appointment.appointmentStatus === "COMPLETED" && (
                        <button type="button"
                            className="btn btn-outline-primary w-100"
                            onClick={() => onViewPrescription(appointment.id)}
                        >
                            View Prescription
                        </button>
                    )
                }


                {/* CANCELLED */}

                {
                    appointment.appointmentStatus === "CANCELLED" && (
                        <div className="alert alert-secondary mb-0">
                            This appointment
                            has been cancelled.
                        </div>
                    )
                }

            </div>

        </div>
    );
}


export default AppointmentCard;

Update MyAppointments.jsx

17

  • Keep your existing prescription logic.
  • Add this function:
const handlePaymentSuccess =
    (updatedAppointment) => {

        setAppointments( previousAppointments =>
                previousAppointments.map(appointment =>
                        appointment.id === updatedAppointment.id
                            ? updatedAppointment : appointment
                )
        );
    };
  • Then update your AppointmentCard:
<AppointmentCard

    appointment={appointment}

    onCancel={handleCancel}

    cancellingId={cancellingId}

    onViewPrescription={handleViewPrescription}

    onPaymentSuccess={handlePaymentSuccess}

/>
  • After successful payment: PENDING + UNPAID changes immediately to: CONFIRMED + PAID without refreshing.

Update BookAppointment.jsx

18

  • At the top add:
import PaymentButton from "../../components/payment/PaymentButton";
  • After :
const [success, setSuccess] = useState(null);
  • add:
const handlePaymentSuccess =  (updatedAppointment) => {
        setSuccess(updatedAppointment);
    };
  • Then replace your current SUCCESS section with:
{success && (
    <div className={success.paymentStatus === "PAID" ? "alert alert-success" : 
                      "alert alert-info"}>

        <h5>
            {success.paymentStatus === "PAID" ? "Appointment Confirmed" : 
                "Appointment Created"}
        </h5>

        <p className="mb-1"> Appointment ID: <strong>{success.id}</strong> </p>
        <p className="mb-1"> Doctor: <strong>{success.doctorName}</strong> </p>
        <p className="mb-1"> Date: <strong>{success.appointmentDate}</strong> </p>
        <p className="mb-1"> Status: <strong>{success.appointmentStatus}</strong></p>
        <p className="mb-3"> Payment: <strong>{success.paymentStatus}</strong> </p>

        {success.paymentStatus === 
               "UNPAID" && success.appointmentStatus === "PENDING" && (
            <PaymentButton appointment={success} 
             onPaymentSuccess={handlePaymentSuccess} />
        )}

        {success.paymentStatus === "PAID" && (
            <Link to="/patient/appointments" className="btn btn-primary">
                View My Appointments
            </Link>
        )}
    </div>
)}
  • And wrap your booking form so it disappears once an appointment has been created
{!success && (

    <div className="card shadow-sm">
      
        {/* your existing booking form */}
      
    </div>

)}
  • This gives:
Book
 ↓
Appointment Created
 ↓
Pay Now
 ↓
Razorpay
 ↓
Appointment Confirmed

Final DoctorAppointmentCard payment rule

19

  • Your doctor currently sees Mark Completed even when appointment is unpaid.
  • Change:


const canComplete = appointment.appointmentStatus !== "COMPLETED"
    &&
    appointment.appointmentStatus !== "CANCELLED";
  • to:
const canComplete = appointment.appointmentStatus ==="CONFIRMED"
    &&
    appointment.paymentStatus ==="PAID";
  • Then add before the canComplete button:
{
    appointment.appointmentStatus ==="PENDING"
    &&
    appointment.paymentStatus ==="UNPAID"
    && (
        <div className="alert alert-warning mb-0"> Waiting for patient payment. </div>
    )
}
  • It will look like this:
import { Link } from "react-router";

function DoctorAppointmentCard({ appointment, onComplete, completingId }) {
    // -------------------------------------------------
    // FORMAT TIME SLOT
    // -------------------------------------------------

    const formatTimeSlot = (timeSlot) => {
        switch (timeSlot) {
            case "MORNING_10_AM":
                return "10:00 AM";

            case "AFTERNOON_2_PM":
                return "2:00 PM";

            case "EVENING_5_PM":
                return "5:00 PM";

            default:
                return timeSlot;
        }
    };
    // -------------------------------------------------
    // APPOINTMENT STATUS BADGE
    // -------------------------------------------------

    const getStatusBadge = (status) => {
        switch (status) {
            case "PENDING":
                return "text-bg-warning";

            case "CONFIRMED":
                return "text-bg-primary";

            case "COMPLETED":
                return "text-bg-success";

            case "CANCELLED":
                return "text-bg-secondary";

            default:
                return "text-bg-light";
        }
    };

    // -------------------------------------------------
    // CAN DOCTOR COMPLETE?
    // -------------------------------------------------

    const canComplete =
        appointment.appointmentStatus === "CONFIRMED" &&
        appointment.paymentStatus === "PAID";
    // -------------------------------------------------
    // WAITING FOR PAYMENT?
    // -------------------------------------------------

    const waitingForPayment =
        appointment.appointmentStatus === "PENDING" &&
        appointment.paymentStatus === "UNPAID";


    return (
        <div className="card shadow-sm h-100">
            <div className="card-body">

                {/* PATIENT + STATUS */}

                <div 
                className="d-flex justify-content-between align-items-start mb-3">
                    <div>
                        <h5 className="mb-1">
                            {appointment.patientName}
                        </h5>

                        <small className="text-secondary">
                            Patient
                        </small>
                    </div>

                    <span className={`badge ${getStatusBadge(
                                     appointment.appointmentStatus)}`}>
                        {appointment.appointmentStatus}
                    </span>
                </div>


                <hr />


                {/* APPOINTMENT ID */}

                <p className="mb-2">
                    <strong>Appointment ID:</strong>{" "}
                    {appointment.id}
                </p>


                {/* DATE */}

                <p className="mb-2">
                    <strong>Date:</strong>{" "}
                    {appointment.appointmentDate}
                </p>


                {/* TIME */}

                <p className="mb-2">
                    <strong>Time:</strong>{" "}
                    {formatTimeSlot(appointment.timeSlot)}
                </p>
                {/* CONSULTATION FEE */}

                <p className="mb-2">
                    <strong>Consultation Fee:</strong>{" "}
                    ₹{appointment.consultationFee}
                </p>


                {/* PAYMENT STATUS */}

                <p className="mb-4">
                    <strong>Payment:</strong>{" "}
                    {appointment.paymentStatus}
                </p>


                {/* WAITING FOR PATIENT PAYMENT */}

                {waitingForPayment && (
                    <div className="alert alert-warning mb-0">
                        Waiting for patient payment.
                    </div>
                )}


                {/* MARK COMPLETED */}

                {canComplete && (
                    <button
                        type="button"
                        className="btn btn-success w-100"
                        onClick={() => onComplete(appointment.id)}
                        disabled={completingId === appointment.id}
                    >
                        {completingId === appointment.id ? "Completing..." :
                                                                  "Mark Completed"}
                    </button>
                )}


                {/* PRESCRIPTION */}

                {appointment.appointmentStatus === "COMPLETED" && (
                    <Link
                        to={`/doctor/appointments/${appointment.id}/prescription`}
                        className="btn btn-primary w-100"
                    >
                        Add Prescription
                    </Link>
                )}


                {/* CANCELLED */}

                {appointment.appointmentStatus === "CANCELLED" && (
                    <div className="alert alert-secondary mb-0">
                        Patient cancelled this appointment.
                    </div>
                )}
            </div>
        </div>
    );
}

export default DoctorAppointmentCard;
  • So the doctor gets:
PENDING + UNPAID
→ Waiting for patient payment

CONFIRMED + PAID
→ Mark Completed

COMPLETED + PAID
→ Add Prescription

Razorpay Dashboard setting

20

  • For this implementation, enable automatic capture.
  • Our backend refuses to mark an appointment paid unless Razorpay returns:
status = captured
  • Razorpay recommends confirming capture before fulfilling the payment.

How to test Phase 13

21

  • Use Razorpay Test Mode. No real money is deducted in test mode.
  • Test this sequence:
1. Start the backend and verify the new database columns.
2. Login as PATIENT and book an appointment (PENDING + UNPAID).
3. Click Pay Now and complete a successful Razorpay test payment.
4. Verify that the appointment becomes CONFIRMED + PAID, with payment details saved.
5. Refresh My Appointments and confirm the status remains unchanged and the Cancel button disappears.
6. Login as DOCTOR, mark the appointment as completed, and verify COMPLETED + PAID.
7. Confirm the prescription flow works normally.
8. Test failed or closed payments; the appointment should remain PENDING + UNPAID, allowing retry or cancellation.
9. Verify that another patient cannot create or verify payments for someone else's appointment.
  • Phase 13 is complete when :
Booking
 ↓
PENDING + UNPAID
 ↓
Pay Now
 ↓
Razorpay Checkout
 ↓
Backend signature verification
 ↓
Backend payment verification
 ↓
CONFIRMED + PAID
 ↓
Doctor completes
 ↓
COMPLETED + PAID
 ↓
Prescription
  • works end-to-end.

We are done with this lab. The latest source code has been uploaded to GitHub. You can access the latest commit using the link below: 

 

Great job!

You have successfully integrated the Razorpay Payment Gateway into the Medicare application!. 

Checkpoint

   Git Push

git push origin branchName

Next-Lab Preparation

Module:

1) React Basics & Backend Integration (Spring Boot)​

SpringBoot Lab - 23

By Content ITV

SpringBoot Lab - 23

  • 90