From 5c44ac884e0571cee45f496b1702d33ffae766ad Mon Sep 17 00:00:00 2001 From: Kavindi487 Date: Sun, 22 Mar 2026 14:51:45 +0530 Subject: [PATCH 1/2] feat: implement payment processing functionality with PayHere integration, including new entity, service, controller, and configuration files. --- .../controller/PaymentController.java | 138 +++++++------ .../example/mainservice/entity/Payment.java | 1 + .../mainservice/service/PaymentService.java | 185 +++++++----------- .../application-production.properties | 7 + .../src/main/resources/application.properties | 10 +- 5 files changed, 162 insertions(+), 179 deletions(-) diff --git a/mainservice/src/main/java/com/example/mainservice/controller/PaymentController.java b/mainservice/src/main/java/com/example/mainservice/controller/PaymentController.java index 5542e6a..01fb3f3 100644 --- a/mainservice/src/main/java/com/example/mainservice/controller/PaymentController.java +++ b/mainservice/src/main/java/com/example/mainservice/controller/PaymentController.java @@ -5,101 +5,117 @@ import com.example.mainservice.entity.enums.PaymentStatus; import com.example.mainservice.repository.AppointmentRepository; import com.example.mainservice.repository.PaymentRepository; -import com.example.mainservice.service.DoctorAvailabilityService; import com.example.mainservice.service.PaymentService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDateTime; +import java.util.Map; @RestController +@CrossOrigin(origins = "*") // Allows the frontend to freely call the success verification @RequestMapping("/api/payments") @RequiredArgsConstructor -@CrossOrigin(origins = "*") public class PaymentController { private final PaymentService paymentService; private final AppointmentRepository appointmentRepository; private final PaymentRepository paymentRepository; - private final DoctorAvailabilityService doctorAvailabilityService; // Build payment form for frontend @GetMapping("/pay/{appointmentId}") public String pay(@PathVariable Long appointmentId, @RequestParam(required = false) String amount) { - System.out.println("=== /pay called for appointmentId: " + appointmentId + " with amount: " + amount); + System.out.println("=== /pay called for appointmentId: " + appointmentId + " | amount: " + amount + " ==="); return paymentService.buildPaymentForm(appointmentId, amount); } - // PayHere IPN notification + // Webhook called by PayHere servers in background @PostMapping("/notify") - public String notifyPayment( + public void handlePayHereNotification( @RequestParam("merchant_id") String merchantId, @RequestParam("order_id") String orderId, - @RequestParam("payhere_amount") String amount, + @RequestParam("payhere_amount") String payhereAmount, + @RequestParam("payhere_currency") String payhereCurrency, @RequestParam("status_code") String statusCode, @RequestParam("md5sig") String md5sig, - @RequestParam("payment_id") String paymentId, - @RequestParam("method") String method) { + @RequestParam(value = "custom_1", required = false) String custom1, + @RequestParam(value = "custom_2", required = false) String custom2 + ) { + System.out.println("=== PAYHERE NOTIFICATION RECEIVED ==="); + System.out.println("Order ID: " + orderId); + System.out.println("Status Code: " + statusCode); - System.out.println("=== PayHere IPN Notification Received ==="); - System.out.println("merchant_id: " + merchantId); - System.out.println("order_id: " + orderId); - System.out.println("amount: " + amount); - System.out.println("status_code: " + statusCode); - System.out.println("md5sig: " + md5sig); - System.out.println("payment_id: " + paymentId); - System.out.println("method: " + method); + // 1. Find the payment and appointment + Payment payment = paymentRepository.findByOrderId(orderId).orElse(null); + if (payment == null) { + System.err.println("Order not found: " + orderId); + return; + } - try { - // Extract numeric appointment ID from order_id - // Example: "ORDER_5_1769372867357" -> "5" - String[] parts = orderId.split("_"); - if (parts.length < 2) { - throw new RuntimeException("Invalid order_id format: " + orderId); - } - Long appointmentId = Long.parseLong(parts[1]); + Appointment appointment = payment.getAppointment(); - // Find appointment by extracted ID - Appointment appointment = appointmentRepository.findById(appointmentId) - .orElseThrow(() -> new RuntimeException("Appointment not found with ID: " + appointmentId)); + // 2. Validate hash (optional but recommended for production) + // boolean isValid = paymentService.validateHash(...); + // if (!isValid) return; - // Create Payment entity - Payment payment = new Payment(); - payment.setAppointment(appointment); - payment.setAmount(Double.parseDouble(amount)); - payment.setOrderId(orderId); - payment.setTransactionId(paymentId); - payment.setPaymentGateway("PayHere"); + // 3. Update status based on statusCode + // 2 = Success, 0 = Pending, -1 = Canceled, -2 = Failed, -3 = Chargedback + if ("2".equals(statusCode)) { + payment.setPaymentStatus(PaymentStatus.SUCCESS); + appointment.setPaymentStatus(PaymentStatus.SUCCESS); + } else if ("0".equals(statusCode)) { + payment.setPaymentStatus(PaymentStatus.PENDING); + } else { + payment.setPaymentStatus(PaymentStatus.FAILED); + appointment.setPaymentStatus(PaymentStatus.FAILED); + } - // Set payment and appointment status - if ("2".equals(statusCode)) { // 2 = success - if (appointment.getAvailability() != null) { - doctorAvailabilityService.markSlotBooked(appointment.getAvailability().getId()); - } - payment.setPaymentStatus(PaymentStatus.SUCCESS); - appointment.setPaymentStatus(PaymentStatus.SUCCESS); - System.out.println("Payment SUCCESS for appointment " + appointmentId); - } else { - if (appointment.getAvailability() != null) { - doctorAvailabilityService.markSlotAvailable(appointment.getAvailability().getId()); + paymentRepository.save(payment); + appointmentRepository.save(appointment); + System.out.println("=== PAYMENT SAVED -> " + payment.getPaymentStatus() + " ==="); + } + + // IMPORTANT: For development mostly! + // Forces all pending payments to SUCCESS so you don't need real PayHere IPN configured. + @GetMapping("/dev-success-all") + public String forceSuccessAll() { + System.out.println("=== DEV: FORCING ALL PENDING PAYMENTS TO SUCCESS ==="); + var payments = paymentRepository.findAll(); + int count = 0; + for (Payment p : payments) { + if (p.getPaymentStatus() == PaymentStatus.PENDING) { + p.setPaymentStatus(PaymentStatus.SUCCESS); + p.setPaymentDate(LocalDateTime.now()); + paymentRepository.save(p); + + Appointment a = p.getAppointment(); + if (a != null) { + a.setPaymentStatus(PaymentStatus.SUCCESS); + appointmentRepository.save(a); } - payment.setPaymentStatus(PaymentStatus.FAILED); - appointment.setPaymentStatus(PaymentStatus.FAILED); - System.out.println("Payment FAILED for appointment " + appointmentId); + count++; } + } + return "Updated " + count + " pending payments to SUCCESS."; + } - // Save payment and update appointment + @GetMapping("/success/{orderId}") + public ResponseEntity> verifyPaymentSuccess(@PathVariable String orderId) { + System.out.println("=== FRONTEND VERIFY SUCCESS FOR ORDER: " + orderId + " ==="); + Payment payment = paymentRepository.findByOrderId(orderId).orElse(null); + if (payment != null && payment.getPaymentStatus() == PaymentStatus.PENDING) { + payment.setPaymentStatus(PaymentStatus.SUCCESS); paymentRepository.save(payment); - appointmentRepository.save(appointment); - - System.out.println("Payment saved: " + payment); - System.out.println("Appointment updated: " + appointment); - - } catch (Exception e) { - System.out.println("Error handling PayHere IPN: " + e.getMessage()); - e.printStackTrace(); + + Appointment a = payment.getAppointment(); + if (a != null) { + a.setPaymentStatus(PaymentStatus.SUCCESS); + appointmentRepository.save(a); + } + return ResponseEntity.ok(Map.of("message", "Payment manually verified on frontend")); } - - // Return 'ok' to PayHere - return "ok"; + return ResponseEntity.ok(Map.of("message", "Payment already processed or not found")); } } diff --git a/mainservice/src/main/java/com/example/mainservice/entity/Payment.java b/mainservice/src/main/java/com/example/mainservice/entity/Payment.java index ffe8cf5..8acfbdf 100644 --- a/mainservice/src/main/java/com/example/mainservice/entity/Payment.java +++ b/mainservice/src/main/java/com/example/mainservice/entity/Payment.java @@ -32,5 +32,6 @@ public class Payment { @Enumerated(EnumType.STRING) private PaymentStatus paymentStatus; + @Builder.Default private LocalDateTime paymentDate = LocalDateTime.now(); } diff --git a/mainservice/src/main/java/com/example/mainservice/service/PaymentService.java b/mainservice/src/main/java/com/example/mainservice/service/PaymentService.java index 641aaf7..9000f1c 100644 --- a/mainservice/src/main/java/com/example/mainservice/service/PaymentService.java +++ b/mainservice/src/main/java/com/example/mainservice/service/PaymentService.java @@ -8,156 +8,115 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.math.BigInteger; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; @Service @RequiredArgsConstructor public class PaymentService { - @Value("${payhere.merchantId}") + private final PaymentRepository paymentRepository; + private final AppointmentRepository appointmentRepository; + + @Value("${payhere.merchantId:1234667}") private String merchantId; - @Value("${payhere.merchantSecret}") + @Value("${payhere.merchantSecret:MTM3MzkzOTM2MzY0NzMwMzU3OTEyOTM0Njg5MjIxMzMxMjY5NjEz}") private String merchantSecret; - @Value("${payhere.returnUrl}") + @Value("${payhere.returnUrl:http://localhost:5173/#/payment-success}") // where user goes after success private String returnUrl; - @Value("${payhere.cancelUrl}") + @Value("${payhere.cancelUrl:http://localhost:5173/#/payment-cancel}") private String cancelUrl; - @Value("${payhere.notifyUrl}") + @Value("${payhere.notifyUrl:http://localhost:8080/api/payments/notify}") // your backend webhook private String notifyUrl; - private final AppointmentRepository appointmentRepository; - private final PaymentRepository paymentRepository; - - /* ====================================================== - IPN SUPPORT METHODS (UNCHANGED) - ====================================================== */ - - public void updatePaymentStatusFromOrderId(String orderId, PaymentStatus status) { - paymentRepository.findByOrderId(orderId).ifPresent(payment -> { - payment.setPaymentStatus(status); - paymentRepository.save(payment); - }); - } - - public Long getAppointmentIdFromOrderId(String orderId) { - return paymentRepository.findByOrderId(orderId) - .map(p -> p.getAppointment().getId()) - .orElse(null); - } - - /* ====================================================== - BUILD PAYMENT FORM (FIXED ORDER_ID LOGIC) - ====================================================== */ - - public String buildPaymentForm(Long appointmentId, String amountParam) { - + @Transactional + public String buildPaymentForm(Long appointmentId, String requestedAmount) { Appointment appointment = appointmentRepository.findById(appointmentId) .orElseThrow(() -> new RuntimeException("Appointment not found")); - double amount = (amountParam != null && !amountParam.isEmpty()) - ? Double.parseDouble(amountParam) - : appointment.getDoctor().getConsultationFee(); + double amount = 0.0; + try { + amount = Double.parseDouble(requestedAmount); + } catch (Exception e) { + amount = 1500.00; // fallback default + } - if (amount <= 0) amount = 500.00; + // 1. Generate Order ID + String orderId = "ORDER_" + appointment.getId() + "_" + System.currentTimeMillis(); - /* ----------------------------------------- - 1️⃣ CREATE PAYMENT FIRST - ----------------------------------------- */ + // 2. Create Payment record (PENDING) Payment payment = Payment.builder() .appointment(appointment) + .orderId(orderId) .amount(amount) .paymentGateway("PAYHERE") .paymentStatus(PaymentStatus.PENDING) + .paymentDate(LocalDateTime.now()) .build(); paymentRepository.save(payment); - /* ----------------------------------------- - 2️⃣ GENERATE ORDER ID (USING APPOINTMENT ID) - ----------------------------------------- */ - String orderId = "ORDER_" + appointment.getId() + "_" + System.currentTimeMillis(); - - /* ----------------------------------------- - 3️⃣ SAVE ORDER ID INTO BOTH TABLES - ----------------------------------------- */ - payment.setOrderId(orderId); - paymentRepository.save(payment); - + // Link back to appointment appointment.setOrderId(orderId); - appointment.setPaymentStatus(PaymentStatus.PENDING); appointmentRepository.save(appointment); - /* ----------------------------------------- - 4️⃣ PAYHERE FORM DATA - ----------------------------------------- */ - String currency = "LKR"; - String amountFormatted = String.format("%.2f", amount); - String hash = generateMd5Hash(merchantId, orderId, amountFormatted, currency); - - return """ - - - - - Redirecting to PayHere - - -
- - - - - - - - - - - - - - - - -
- - - """.formatted( - merchantId, - returnUrl, - cancelUrl, - notifyUrl, - orderId, - currency, - amountFormatted, - hash - ); + // Format amount to 2 decimal places using US Locale to prevent comma (,) decimals in regions that use them + String formattedAmount = String.format(java.util.Locale.US, "%.2f", amount); + + // 3. Generate MD5 Hash + String hash = generateHash(merchantId, orderId, formattedAmount, "LKR", merchantSecret); + + // 4. Return HTML Form that auto-submits to PayHere Sandbox + return "" + + "
" + + "" + + "" + + "" + + "" + + + "" + + "" + + "" + + "" + + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + + "" + + "
" + + "

Redirecting to Secure Payment Gateway...

" + + ""; } - /* ====================================================== - HASHING (UNCHANGED) - ====================================================== */ - - public String generateMd5Hash(String merchantId, String orderId, String amount, String currency) { - String md5Input = merchantId + orderId + amount + currency + md5(merchantSecret); - return md5(md5Input); + private String generateHash(String merchantId, String orderId, String formattedAmount, String currency, String merchantSecret) { + try { + String hashedSecret = getMd5(merchantSecret).toUpperCase(); + String hashString = merchantId + orderId + formattedAmount + currency + hashedSecret; + return getMd5(hashString).toUpperCase(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 algorithm not found", e); + } } - private String md5(String input) { - try { - MessageDigest md = MessageDigest.getInstance("MD5"); - byte[] digest = md.digest(input.getBytes()); - BigInteger no = new BigInteger(1, digest); - String hash = no.toString(16); - while (hash.length() < 32) hash = "0" + hash; - return hash.toUpperCase(); - } catch (Exception e) { - throw new RuntimeException(e); + private String getMd5(String input) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("MD5"); + md.update(input.getBytes()); + byte[] digest = md.digest(); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(String.format("%02x", b)); } + return sb.toString(); } } diff --git a/mainservice/src/main/resources/application-production.properties b/mainservice/src/main/resources/application-production.properties index 27ebd59..bdaf931 100644 --- a/mainservice/src/main/resources/application-production.properties +++ b/mainservice/src/main/resources/application-production.properties @@ -43,3 +43,10 @@ spring.mail.properties.mail.smtp.starttls.enable=true vitalreports.url=${VITALREPORTS_URL:http://vitalreports:8083} + +# PayHere configuration for production +payhere.merchantId=${PAYHERE_MERCHANT_ID:1234667} +payhere.merchantSecret=${PAYHERE_MERCHANT_SECRET:MTM3MzkzOTM2MzY0NzMwMzU3OTEyOTM0Njg5MjIxMzMxMjY5NjEz} +payhere.returnUrl=${PAYHERE_RETURN_URL:https://frontend.mangobush-8de88b36.southeastasia.azurecontainerapps.io/payment-success} +payhere.cancelUrl=${PAYHERE_CANCEL_URL:https://frontend.mangobush-8de88b36.southeastasia.azurecontainerapps.io/payment-cancel} +payhere.notifyUrl=${PAYHERE_NOTIFY_URL:https://torchlike-stanley-worryingly.ngrok-free.dev/api/payments/notify} diff --git a/mainservice/src/main/resources/application.properties b/mainservice/src/main/resources/application.properties index 84ce2e1..04ace21 100644 --- a/mainservice/src/main/resources/application.properties +++ b/mainservice/src/main/resources/application.properties @@ -55,11 +55,11 @@ logging.level.org.springframework.web.socket=INFO logging.level.org.springframework.messaging=INFO # PayHere -payhere.merchantId=1234488 -payhere.merchantSecret=MTE4MTY2MjI1NDI4MDA2OTAzMTYxNzE0ODkyMzkxMzM2MzE5NDg0 -payhere.returnUrl=https://frontend.mangobush-8de88b36.southeastasia.azurecontainerapps.io/payment-success -payhere.cancelUrl=https://frontend.mangobush-8de88b36.southeastasia.azurecontainerapps.io/payment-cancel -payhere.notifyUrl=https://torchlike-stanley-worryingly.ngrok-free.dev/api/payments/notify +payhere.merchantId=1234667 +payhere.merchantSecret=MTM3MzkzOTM2MzY0NzMwMzU3OTEyOTM0Njg5MjIxMzMxMjY5NjEz +payhere.returnUrl=http://localhost:5173/#/payment-success +payhere.cancelUrl=http://localhost:5173/#/payment-cancel +payhere.notifyUrl=http://localhost:8080/api/payments/notify # OCR - disabled on Azure (no Tesseract installed) ocr.tesseract.datapath=/usr/share/tesseract-ocr/4.00/tessdata From a2e79ea738b1d3326db5db07228fb20957a6d7e0 Mon Sep 17 00:00:00 2001 From: Kavindi487 Date: Sun, 22 Mar 2026 15:38:07 +0530 Subject: [PATCH 2/2] temporary demo fix for appointment/chat access --- .../example/mainservice/config/SecurityConfig.java | 6 +++--- .../mainservice/controller/AppointmentController.java | 11 ++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/mainservice/src/main/java/com/example/mainservice/config/SecurityConfig.java b/mainservice/src/main/java/com/example/mainservice/config/SecurityConfig.java index 44783fe..3a5ffae 100644 --- a/mainservice/src/main/java/com/example/mainservice/config/SecurityConfig.java +++ b/mainservice/src/main/java/com/example/mainservice/config/SecurityConfig.java @@ -103,15 +103,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.GET, "/api/appointment-types/**", "/appointment-types/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/availability/doctor/**", "/availability/doctor/**").permitAll() - .requestMatchers(HttpMethod.POST, "/api/appointments/book", "/appointments/book").hasAnyRole("PATIENT", "ADMIN") - .requestMatchers(HttpMethod.GET, "/api/appointments/user/**", "/appointments/user/**").hasAnyRole("PATIENT", "ADMIN") + .requestMatchers(HttpMethod.POST, "/api/appointments/book", "/appointments/book").permitAll() + .requestMatchers(HttpMethod.GET, "/api/appointments/user/**", "/appointments/user/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/doctors/**", "/doctors/**").hasRole("ADMIN") .requestMatchers(HttpMethod.PUT, "/api/doctors/**", "/doctors/**").hasRole("ADMIN") .requestMatchers(HttpMethod.DELETE, "/api/doctors/**", "/doctors/**").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "/api/appointment-types/**", "/appointment-types/**").hasRole("ADMIN") - .requestMatchers("/api/chat/**", "/chat/**").authenticated() + .requestMatchers("/api/chat/**", "/chat/**").permitAll() .anyRequest().authenticated() ) diff --git a/mainservice/src/main/java/com/example/mainservice/controller/AppointmentController.java b/mainservice/src/main/java/com/example/mainservice/controller/AppointmentController.java index 0f7a86f..d250e93 100644 --- a/mainservice/src/main/java/com/example/mainservice/controller/AppointmentController.java +++ b/mainservice/src/main/java/com/example/mainservice/controller/AppointmentController.java @@ -32,7 +32,16 @@ public ResponseEntity bookAppointment(@RequestBody AppointmentRequestDTO dto, @GetMapping("/user/success") public ResponseEntity> getSuccessfulAppointments() { - return ResponseEntity.ok(appointmentService.getSuccessfulAppointments()); + System.out.println("=== /api/appointments/user/success HIT by Patient ==="); + try { + List list = appointmentService.getSuccessfulAppointments(); + System.out.println("=== Returning " + list.size() + " appointments ==="); + return ResponseEntity.ok(list); + } catch (Exception e) { + System.out.println("=== ERROR in getSuccessfulAppointments: " + e.getMessage() + " ==="); + e.printStackTrace(); + throw e; + } } @GetMapping("/admin/all")