Medicare Final Data Setup, Integration & Application Testing

Business Scenario

In this lab, you will prepare the Medicare application for final verification by setting up test doctor accounts and profiles, then testing the complete application through the React frontend.

Pre-Lab Preparation

Module:

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

Phase 15 — Final Data Setup + API & Frontend Testing

  • There are only 3 things to do:
1. Add enough test data
2. Test the complete application from the frontend
3. Run a small final API/security test

Add test data first

1

  • Do not manually insert:
Appointments 
Payments 
Prescriptions 
Patients 
  • We want the frontend to create those because those are exactly the flows we need to test.
  • The only thing we really need to pre-populate is:
Doctors
  • Because the patient needs doctors available to search and book.
  • I recommend having at least 7 doctors, because your frontend pagination size is: 6
  • So 7 doctors lets us test:
Page 1 → 6 doctors
Page 2 → remaining doctor

Doctor test accounts

2

DoctorEmailSpecializationExperienceFee
Dr. Ananya Sharmaananya.doctor@example.comCardiologist121800
Dr. Rohit Mehtarohit.doctor@example.comOrthopedic81200
Dr. Neha Patilneha.doctor@example.comDentist6800
Dr. Arjun Raoarjun.doctor@example.comGeneral Physician10700
Dr. Kavya Iyerkavya.doctor@example.comCardiologist51400
Dr. Sameer Khansameer.doctor@example.comOrthopedic152000
Dr. Priya Nairpriya.doctor@example.comDentist91000
  • Use this password for all test doctors:
Doctor@123

Easiest way to insert them

3

  • Because your public registration intentionally creates: PATIENT and not DOCTOR, don't modify your registration API just for testing.
  • Use MySQL once to seed the doctor accounts.
  • Run:
USE medicare_db;
INSERT IGNORE INTO users
(name, email, phone, role, password)
VALUES

(
    'Dr. Ananya Sharma',
    'ananya.doctor@example.com',
    '9000000001',
    'DOCTOR',
    '$2y$10$9vkfZH2psSI9/RAWTZ8bCOAt7J40JPHyhJEPg82RhsR4AvFZza71S'
),

(
    'Dr. Rohit Mehta',
    'rohit.doctor@example.com',
    '9000000002',
    'DOCTOR',
    '$2y$10$9vkfZH2psSI9/RAWTZ8bCOAt7J40JPHyhJEPg82RhsR4AvFZza71S'
),

(
    'Dr. Neha Patil',
    'neha.doctor@example.com',
    '9000000003',
    'DOCTOR',
    '$2y$10$9vkfZH2psSI9/RAWTZ8bCOAt7J40JPHyhJEPg82RhsR4AvFZza71S'
),
(
    'Dr. Arjun Rao',
    'arjun.doctor@example.com',
    '9000000004',
    'DOCTOR',
    '$2y$10$9vkfZH2psSI9/RAWTZ8bCOAt7J40JPHyhJEPg82RhsR4AvFZza71S'
),
(
    'Dr. Kavya Iyer',
    'kavya.doctor@example.com',
    '9000000005',
    'DOCTOR',
    '$2y$10$9vkfZH2psSI9/RAWTZ8bCOAt7J40JPHyhJEPg82RhsR4AvFZza71S'
),
(
    'Dr. Sameer Khan',
    'sameer.doctor@example.com',
    '9000000006',
    'DOCTOR',
    '$2y$10$9vkfZH2psSI9/RAWTZ8bCOAt7J40JPHyhJEPg82RhsR4AvFZza71S'
),
(
    'Dr. Priya Nair',
    'priya.doctor@example.com',
    '9000000007',
    'DOCTOR',
    '$2y$10$9vkfZH2psSI9/RAWTZ8bCOAt7J40JPHyhJEPg82RhsR4AvFZza71S'
);
  • That BCrypt hash represents: Doctor@123

Insert doctor profiles

4

  • Then run:
INSERT INTO doctor_profiles 
(user_id, specialization, experience_years, consultation_fee, photo_url)
SELECT id, 'Cardiologist', 12, 1800.00, NULL
FROM users WHERE email = 'ananya.doctor@example.com'
AND NOT EXISTS (
    SELECT 1
    FROM doctor_profiles
    WHERE user_id = users.id
);


INSERT INTO doctor_profiles 
(user_id, specialization, experience_years, consultation_fee, photo_url)
SELECT id, 'Orthopedic', 8, 1200.00, NULL
FROM users WHERE email = 'rohit.doctor@example.com'
AND NOT EXISTS (
    SELECT 1
    FROM doctor_profiles
    WHERE user_id = users.id
);


INSERT INTO doctor_profiles 
(user_id, specialization, experience_years, consultation_fee, photo_url)
SELECT id, 'Dentist', 6, 800.00, NULL
FROM users WHERE email = 'neha.doctor@example.com'
AND NOT EXISTS (
    SELECT 1
    FROM doctor_profiles
    WHERE user_id = users.id
);


INSERT INTO doctor_profiles 
(user_id, specialization, experience_years, consultation_fee, photo_url)
SELECT id, 'General Physician', 10, 700.00, NULL
FROM users WHERE email = 'arjun.doctor@example.com'
AND NOT EXISTS (
    SELECT 1
    FROM doctor_profiles
    WHERE user_id = users.id
);


INSERT INTO doctor_profiles 
(user_id, specialization, experience_years, consultation_fee, photo_url)
SELECT id, 'Cardiologist', 5, 1400.00, NULL
FROM users WHERE email = 'kavya.doctor@example.com'
AND NOT EXISTS (
    SELECT 1
    FROM doctor_profiles
    WHERE user_id = users.id
);


INSERT INTO doctor_profiles 
(user_id, specialization, experience_years, consultation_fee, photo_url)
SELECT id, 'Orthopedic', 15, 2000.00, NULL
FROM users WHERE email = 'sameer.doctor@example.com'
AND NOT EXISTS (
    SELECT 1
    FROM doctor_profiles
    WHERE user_id = users.id
);


INSERT INTO doctor_profiles 
(user_id, specialization, experience_years, consultation_fee, photo_url)
SELECT id, 'Dentist', 9, 1000.00, NULL
FROM users WHERE email = 'priya.doctor@example.com'
AND NOT EXISTS (
    SELECT 1
    FROM doctor_profiles
    WHERE user_id = users.id
);
  • That's enough test data. Don't insert appointments manually.

Verify doctor data once

5

  • Before frontend testing, open:
GET http://localhost:8080/api/doctors?page=0&size=6
  • with a valid JWT.
  • You should see:
totalElements = 7
totalPages = 2
  • Once that works, stop using Postman for normal functionality
  • From this point, test through React.

Start frontend testing

6

  • Frontend:
http://localhost:5173
  • Backend:
http://localhost:8080
  • Now test in exactly this order.

Test 1 — Patient registration

Name: Rahul Patil
Email: rahul.patient@example.com
Phone: 9876543210
Password: Patient@123
  • Go to: http://localhost:5173/register
  • Create a brand-new patient:
  • Expected:
Registration successful 
Role = PATIENT 

Test 2 — Patient login

  • Go to : http://localhost:5173/login
  • Use:
rahul.patient@example.com
Patient@123
  • Expected: Patient Dashboard
  • Also refresh the browser once. You should remain logged in.

Test 3 — My Profile

  • Open :
Patient Dashboard
→ My Profile
  • Verify:
Rahul Patil
rahul.patient@example.com
9876543210
PATIENT

Test 4 — Doctor listing

  • Go to:
Patient Dashboard
→ Find Doctor
  • You should see the doctors we inserted.
  • Now test:
Search by name
Specialization
Maximum fee
Sort by fee
Sort by name
Sort by experience
Ascending
Descending
Reset Filters
  • Also verify:
Page 1
Page 2

Test 5 — Book appointment

  • For the main test, choose:
Dr. Ananya Sharma
Cardiologist
  • Select:
​Future date 10:00 AM
  • Book.
  • Expected:
Appointment Created

PENDING
UNPAID
  • This proves appointment creation works.

Test 6 — Razorpay payment

  • Click: Pay Now
  • Razorpay Checkout should open.
  • Use Razorpay Test Mode only.
  • For current Razorpay sandbox testing, their published test guidance includes:
success@razorpay
  • for a simulated successful UPI payment, and:
failure@razorpay
  • for a simulated failure.
  • Expected after backend verification
Appointment Status: CONFIRMED
Payment Status: PAID

Test 7 — Email

  • After successful payment, check the email registered for the patient.
  • Expected confirmation details:
Patient name
Doctor
Specialization
Date
Slot
Fee
CONFIRMED
PAID

Test 8 — My Appointments

  • Go to:
Patient Dashboard
→ My Appointments
  • The appointment should show:
Dr. Ananya Sharma
CONFIRMED
PAID
  • And: Cancel Appointment should not be available for the paid appointment.

Test 9 — Doctor login

  • Logout.
ananya.doctor@example.com
Doctor@123
  • Login with:
  • Expected redirect: Doctor Dashboard

Test 10 — Doctor profile

  • Open :
Doctor Dashboard
→ My Profile
  • You should see:
Cardiologist
12 years
₹1800
  • Change something temporarily, for example:
Experience:
12 → 13
  • Save
  • Verify the updated value appears.
  • Then you can return it to 12.
  • That is enough to test profile update.
  • No need to delete a working doctor profile.

Test 11 — Doctor schedule

  • Go to:
Doctor Dashboard
→ My Schedule
  • The appointment booked by Rahul should appear.
CONFIRMED + PAID
  • Because it is:
  • you should see:
Mark Completed
  • Click it.
  • Expected:
COMPLETED + PAID

Test 12 — Prescription

  • After completing the appointment:
Add Prescription
  • Use:
Diagnosis: Seasonal viral fever
Doctor Notes: Drink plenty of fluids and take adequate rest.
  • Medicine 1:
Medicine Name: Paracetamol
Dosage: 500 mg
Frequency: Twice daily
Duration: 3 days
Medicine Name: Vitamin C
Dosage: 500 mg
Frequency: Once daily
Duration: 5 days
  • Medicine 2:
  • Save.
  • Expected: Prescription created successfully

Test 13 — Patient views prescription

  • Logout doctor.
rahul.patient@example.com
Patient@123
  • Login again:
  • Go to My Appoinments
  • The appointment should be:
COMPLETED
PAID
  • Click:
View Prescription
  • Verify:
Doctor name
Diagnosis
Paracetamol
Vitamin C
Dosage
Frequency
Duration
Doctor Notes
  • If this works, your main end-to-end project flow is complete.

Test 14 — Cancellation flow

  • We need one more appointment because cancellation is also a feature.
  • Book another appointment with, for example:
Dr. Rohit Mehta
  • Choose another future date/slot.
  • Do not pay.
  • It should be:
  • Pay Now should no longer be available.
PENDING
UNPAID
  • Open: My Appointments
  • Click: Cancel Appointment
  • Expected:
CANCELLED
UNPAID
    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)​