diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbb3992 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# ๐ŸŒŸ DivineSpark โ€” Live Session Booking & Payments Platform + +DivineSpark is a **production-ready, client-facing web platform** for managing and booking live spiritual and wellness sessions. +The system is designed with real-world requirements such as **secure authentication, role-based access, installment payments, admin control, and operational automation**. + +--- + +## ๐Ÿš€ Key Features + +### ๐Ÿ” Authentication & Security +- Email-based authentication with JWT +- Role-based access control (USER / ADMIN) +- Secured APIs using Spring Security +- Controlled access to session join links based on payment eligibility + +--- + +### ๐Ÿ“… Session Management +- Admin can create, update, and delete sessions +- Supports both **Free** and **Paid** sessions +- Seat availability management +- Users can view and book upcoming sessions + +--- + +### ๐Ÿ’ณ Payments & Installments +- Integrated Razorpay for secure online payments +- Supports: + - One-time full payments + - Installment-based payments +- Tracks: + - Paid amount + - Remaining amount + - Installment status per booking +- Booking lifecycle states: + - `PENDING` + - `PARTIALLY_PAID` + - `CONFIRMED` + - `CANCELLED` + +--- + +### ๐Ÿ”— Controlled Session Access +- Session join (WhatsApp) links are exposed only when eligibility is verified +- Access allowed only for `CONFIRMED` or `PARTIALLY_PAID` users +- Prevents unauthorized sharing or misuse of session links + +--- + +### ๐Ÿ›  Admin Features +- View users registered for each session +- Monitor installment payments per user +- Approve or reject user reviews +- Manage blogs, reviews, and events +- Export user, booking, and payment data as CSV for reporting + +--- + +### โš™๏ธ Automation & Maintenance +- Designed to support scheduled CRON jobs +- Periodic database cleanup and archival (e.g., every 3 months) +- Helps reduce storage cost and maintain long-term performance + +--- + +## ๐Ÿงฑ Tech Stack + +### Backend +- Java 17 +- Spring Boot +- Spring Security (JWT) +- Spring Data JPA +- MySQL +- Razorpay Payment Gateway + +### Frontend +- React +- TypeScript +- Axios +- Mobile-friendly and responsive UI + +### Tools & Concepts +- REST APIs +- Webhooks for payment events +- Cron jobs for automation +- CSV export for admin reporting + +--- + +## ๐Ÿ— Architecture Overview + +- Stateless backend with JWT-based authentication +- Clear separation of layers: + - Controllers + - Services + - Repositories +- Payment handling via: + - Frontend verification (primary) + - Webhook-based confirmation (fallback) +- Booking records preserved as immutable financial history +- Business rules enforced at the service layer + +--- + +## ๐Ÿ”„ Booking & Payment Flow + +1. User browses available sessions +2. Selects free or paid session +3. For paid sessions: + - Chooses full payment or installment option +4. Payment processed via Razorpay +5. Booking status updated based on payment progress +6. Session access granted only to eligible users + +--- + +## ๐Ÿงช Testing & Validation + +- Manual testing of: + - Free session booking + - Full payment flow + - Installment payments + - Cancelled booking re-booking + - Admin workflows +- Payment edge cases handled: + - Webhook retries + - Partial payments + - Duplicate payment prevention + +--- + +## ๐Ÿ“ฆ Project Status + +- Core features implemented +- Backend is production-ready +- Continuous enhancements in progress + +--- + +## ๐Ÿ“Œ Planned Enhancements +- Automated installment reminder notifications +- Admin payment reconciliation dashboard +- Advanced analytics and reporting +- Full CRON-based archival pipeline +- Cloud deployment with monitoring + +--- + +## ๐Ÿ‘ฉโ€๐Ÿ’ป Project Context + +DivineSpark is a **client-facing production project**, built with a strong focus on real-world payment flows, secure access control, operational automation, and long-term maintainability. + +--- + +## ๐Ÿ“„ License + +This project is proprietary and developed for a client use case. +Reuse or redistribution requires prior permission. diff --git a/divinespark-backend/src/main/java/com/divinespark/controller/user/PaymentVerificationController.java b/divinespark-backend/src/main/java/com/divinespark/controller/user/PaymentVerificationController.java index 06b776c..7da61fe 100644 --- a/divinespark-backend/src/main/java/com/divinespark/controller/user/PaymentVerificationController.java +++ b/divinespark-backend/src/main/java/com/divinespark/controller/user/PaymentVerificationController.java @@ -19,9 +19,22 @@ public PaymentVerificationController(InstallementService installmentService) { @PostMapping("/installment/verify") public ResponseEntity verifyInstallment( - @RequestParam String razorpayOrderId + @RequestParam String razorpayOrderId, + @RequestParam String razorpayPaymentId, + @RequestParam String razorpaySignature ) { - installmentService.markInstallmentPaid(razorpayOrderId); + System.out.println(">>> VERIFY API HIT <<<"); + System.out.println("orderId = " + razorpayOrderId); + System.out.println("paymentId = " + razorpayPaymentId); + System.out.println("signature = " + razorpaySignature); + + installmentService.verifyAndMarkInstallmentPaid( + razorpayOrderId, + razorpayPaymentId, + razorpaySignature + ); + return ResponseEntity.ok().build(); } -} + +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/controller/user/ReviewController.java b/divinespark-backend/src/main/java/com/divinespark/controller/user/ReviewController.java index db2733e..1f30f15 100644 --- a/divinespark-backend/src/main/java/com/divinespark/controller/user/ReviewController.java +++ b/divinespark-backend/src/main/java/com/divinespark/controller/user/ReviewController.java @@ -31,7 +31,6 @@ public ResponseEntity submitReview( return ResponseEntity.ok().build(); } - @PreAuthorize("hasRole('USER')") @GetMapping public ResponseEntity getMyReview( @AuthenticationPrincipal CustomUserDetails userDetails diff --git a/divinespark-backend/src/main/java/com/divinespark/dto/BlogDetailResponse.java b/divinespark-backend/src/main/java/com/divinespark/dto/BlogDetailResponse.java index 6b8a3e5..6635753 100644 --- a/divinespark-backend/src/main/java/com/divinespark/dto/BlogDetailResponse.java +++ b/divinespark-backend/src/main/java/com/divinespark/dto/BlogDetailResponse.java @@ -10,6 +10,16 @@ public class BlogDetailResponse { private String authorRole; private OffsetDateTime createdAt; + + private Boolean isPublished; + + public Boolean getIsPublished() { + return isPublished; + } + public void setIsPublished(Boolean isPublished) { + this.isPublished = isPublished; + } + public String getTitle() { return title; } @@ -49,4 +59,4 @@ public OffsetDateTime getCreatedAt() { public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/dto/BlogListResponse.java b/divinespark-backend/src/main/java/com/divinespark/dto/BlogListResponse.java index da4c3a4..090791d 100644 --- a/divinespark-backend/src/main/java/com/divinespark/dto/BlogListResponse.java +++ b/divinespark-backend/src/main/java/com/divinespark/dto/BlogListResponse.java @@ -12,6 +12,16 @@ public class BlogListResponse { private Long id; + private Boolean isPublished; + + public Boolean getIsPublished() { + return isPublished; + } + public void setIsPublished(Boolean isPublished) { + this.isPublished = isPublished; + } + + public String getAuthorName() { return authorName; } @@ -68,4 +78,4 @@ public String getTitle() { public void setTitle(String title) { this.title = title; } -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/dto/SessionCreateRequest.java b/divinespark-backend/src/main/java/com/divinespark/dto/SessionCreateRequest.java index 436d70b..8bc8684 100644 --- a/divinespark-backend/src/main/java/com/divinespark/dto/SessionCreateRequest.java +++ b/divinespark-backend/src/main/java/com/divinespark/dto/SessionCreateRequest.java @@ -13,7 +13,7 @@ public class SessionCreateRequest { private double price; private String whatsappGroupLink; - // โœ… Frontend sends OffsetDateTime with +05:30 + // Frontend sends OffsetDateTime with +05:30 private OffsetDateTime startTime; private OffsetDateTime endTime; diff --git a/divinespark-backend/src/main/java/com/divinespark/entity/Booking.java b/divinespark-backend/src/main/java/com/divinespark/entity/Booking.java index 1182cde..cec4bef 100644 --- a/divinespark-backend/src/main/java/com/divinespark/entity/Booking.java +++ b/divinespark-backend/src/main/java/com/divinespark/entity/Booking.java @@ -7,8 +7,7 @@ import java.time.OffsetDateTime; @Entity -@Table(name = "bookings", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "session_id"})) +@Table(name = "bookings") public class Booking { @Id diff --git a/divinespark-backend/src/main/java/com/divinespark/entity/Event.java b/divinespark-backend/src/main/java/com/divinespark/entity/Event.java index 334707c..fe62133 100644 --- a/divinespark-backend/src/main/java/com/divinespark/entity/Event.java +++ b/divinespark-backend/src/main/java/com/divinespark/entity/Event.java @@ -22,7 +22,7 @@ public class Event { @Column(nullable = false) private OffsetDateTime startTime; - @Column(nullable = false) + @Column private Integer durationMinutes; // auto-expiry logic uses this @@ -81,4 +81,4 @@ public OffsetDateTime getCreatedAt() { public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/repository/BookingRepository.java b/divinespark-backend/src/main/java/com/divinespark/repository/BookingRepository.java index 7055b98..c555c81 100644 --- a/divinespark-backend/src/main/java/com/divinespark/repository/BookingRepository.java +++ b/divinespark-backend/src/main/java/com/divinespark/repository/BookingRepository.java @@ -24,22 +24,18 @@ public interface BookingRepository extends JpaRepository { """) List findUserBookingsWithSession(Long userId); - Optional findByUser_IdAndSession_Id( - Long userId, - Long sessionId - ); + Optional findByUserIdAndSessionId(Long userId, Long sessionId); - boolean existsByUser_IdAndSession_Id( - Long userId, - Long sessionId - ); - boolean existsByUser_IdAndSession_IdAndBookingStatus( + Optional findFirstByUser_IdAndSession_IdAndBookingStatusInOrderByCreatedAtDesc( Long userId, Long sessionId, - BookingStatus bookingStatus + List statuses ); + + + // ---------- ADMIN ---------- @Query(""" SELECT new com.divinespark.dto.AdminSessionUserResponse( @@ -101,5 +97,12 @@ List findBookingEntitiesBySessionId( @Param("sessionId") Long sessionId ); + boolean existsByUserIdAndSessionIdAndBookingStatusIn( + Long userId, + Long sessionId, + List statuses + ); + + -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/repository/InstallmentRepository.java b/divinespark-backend/src/main/java/com/divinespark/repository/InstallmentRepository.java index 694b352..1d90f9b 100644 --- a/divinespark-backend/src/main/java/com/divinespark/repository/InstallmentRepository.java +++ b/divinespark-backend/src/main/java/com/divinespark/repository/InstallmentRepository.java @@ -37,5 +37,9 @@ void updateOrderId( @Param("orderId") String orderId ); + @Modifying + @Query("delete from Installment i where i.booking.id = :bookingId") + void deleteByBookingId(@Param("bookingId") Long bookingId); + -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/repository/PaymentRepository.java b/divinespark-backend/src/main/java/com/divinespark/repository/PaymentRepository.java index 3a73a9a..6978a29 100644 --- a/divinespark-backend/src/main/java/com/divinespark/repository/PaymentRepository.java +++ b/divinespark-backend/src/main/java/com/divinespark/repository/PaymentRepository.java @@ -10,7 +10,7 @@ public interface PaymentRepository extends JpaRepository { - Payment findByGatewayOrderId(String gatewayOrderId); + Optional findByGatewayOrderId(String gatewayOrderId); Optional findTopByBookingIdOrderByCreatedAtDesc(Long bookingId); diff --git a/divinespark-backend/src/main/java/com/divinespark/security/SecurityConfig.java b/divinespark-backend/src/main/java/com/divinespark/security/SecurityConfig.java index ef9dd34..d49abf6 100644 --- a/divinespark-backend/src/main/java/com/divinespark/security/SecurityConfig.java +++ b/divinespark-backend/src/main/java/com/divinespark/security/SecurityConfig.java @@ -6,8 +6,6 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -55,11 +53,20 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/v1/user/review/**" ).permitAll() - // User APIs - .requestMatchers("/api/v1/user/**", "/api/v1/installments/**, ", "/api/v1/payments").hasRole("USER") + // PUBLIC: session browsing & reviews (NO LOGIN) + .requestMatchers( + HttpMethod.GET, + "/api/v1/sessions/**", + "/api/v1/user/review/**", + "/api/v1/blogs/**" + ).permitAll() - // Public session browsing - .requestMatchers(HttpMethod.GET, "/api/v1/sessions/**").permitAll() + // User APIs (LOGIN REQUIRED) + .requestMatchers( + "/api/v1/user/**", + "/api/v1/installments/**", + "/api/v1/payments/**" + ).hasRole("USER") // Admin APIs .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") @@ -67,6 +74,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .anyRequest().authenticated() ) + // THIS PREVENTS GOOGLE REDIRECTS // .exceptionHandling(ex -> ex // .authenticationEntryPoint((request, response, authException) -> { @@ -123,4 +131,4 @@ public CorsConfigurationSource corsConfigurationSource() { // public PasswordEncoder passwordEncoder() { // return new BCryptPasswordEncoder(); // } -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/service/AuthService.java b/divinespark-backend/src/main/java/com/divinespark/service/AuthService.java index 74e644f..d1c1d08 100644 --- a/divinespark-backend/src/main/java/com/divinespark/service/AuthService.java +++ b/divinespark-backend/src/main/java/com/divinespark/service/AuthService.java @@ -19,11 +19,10 @@ public class AuthService { private final UserRepository userRepository; - private final OtpRepository otpRepository; // โœ… ADD THIS + private final OtpRepository otpRepository; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; - // โœ… UPDATE CONSTRUCTOR public AuthService( UserRepository userRepository, OtpRepository otpRepository, @@ -43,7 +42,13 @@ public void register(RegisterRequest request) { throw new RuntimeException("Email is already in use"); } - if (!request.getContactNumber().matches("^[6-9]\\d{9}$")) { + String phone = request.getContactNumber().replaceAll("\\D", ""); + + if (phone.startsWith("91") && phone.length() == 12) { + phone = phone.substring(2); + } + + if (!phone.matches("^[6-9]\\d{9}$")) { throw new RuntimeException("Invalid contact number"); } @@ -51,14 +56,13 @@ public void register(RegisterRequest request) { .username(request.getUsername()) .email(request.getEmail()) .password(passwordEncoder.encode(request.getPassword())) - .contactNumber(request.getContactNumber()) + .contactNumber(phone) .role(Role.USER) .isActive(true) .build(); userRepository.save(user); } - // ================= LOGIN ================= public String login(LoginRequest request) { diff --git a/divinespark-backend/src/main/java/com/divinespark/service/InstallementService.java b/divinespark-backend/src/main/java/com/divinespark/service/InstallementService.java index fbe7acf..ae587c8 100644 --- a/divinespark-backend/src/main/java/com/divinespark/service/InstallementService.java +++ b/divinespark-backend/src/main/java/com/divinespark/service/InstallementService.java @@ -12,8 +12,8 @@ public interface InstallementService { List getInstallmentsByBooking(Long bookingId, Long userId); - void markInstallmentPaid(String razorpayOrderId); + boolean verifyAndMarkInstallmentPaid (String orderId, + String paymentId, + String signature); - - -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/service/RazorpayService.java b/divinespark-backend/src/main/java/com/divinespark/service/RazorpayService.java index 44870e3..eefff77 100644 --- a/divinespark-backend/src/main/java/com/divinespark/service/RazorpayService.java +++ b/divinespark-backend/src/main/java/com/divinespark/service/RazorpayService.java @@ -4,4 +4,10 @@ public interface RazorpayService { RazorpayOrderResponse createOrder(int amount, Long bookingId); -} + + boolean verifySignature( + String razorpayOrderId, + String razorpayPaymentId, + String razorpaySignature + ); +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/service/impl/BlogServiceImpl.java b/divinespark-backend/src/main/java/com/divinespark/service/impl/BlogServiceImpl.java index d8d22e2..bb428fa 100644 --- a/divinespark-backend/src/main/java/com/divinespark/service/impl/BlogServiceImpl.java +++ b/divinespark-backend/src/main/java/com/divinespark/service/impl/BlogServiceImpl.java @@ -41,6 +41,7 @@ public void createBlog(BlogCreateRequest request) { blog.setAuthorName(request.getAuthorName()); blog.setAuthorRole(request.getAuthorRole()); blog.setStatus(BlogStatus.DRAFT); + // Removed setCreatedAt and setUpdatedAt as they are handled automatically by your Entity blogRepository.save(blog); } @@ -84,12 +85,17 @@ public List getPublishedBlogs() { for (Blog b : blogs) { BlogListResponse dto = new BlogListResponse(); + dto.setId(b.getId()); dto.setTitle(b.getTitle()); dto.setSlug(b.getSlug()); dto.setExcerpt(b.getExcerpt()); dto.setAuthorName(b.getAuthorName()); dto.setAuthorRole(b.getAuthorRole()); dto.setCreatedAt(b.getCreatedAt()); + + // โœ… Set published to true for this list + dto.setIsPublished(true); + response.add(dto); } @@ -110,18 +116,20 @@ public BlogDetailResponse getBlogBySlug(String slug) { res.setAuthorRole(blog.getAuthorRole()); res.setCreatedAt(blog.getCreatedAt()); + // โœ… Set published status + res.setIsPublished(true); + return res; } // ---------- SLUG GENERATOR ---------- private String generateSlug(String input) { - String slug = Normalizer.normalize(input, Normalizer.Form.NFD) + if (input == null) return ""; + return Normalizer.normalize(input, Normalizer.Form.NFD) .replaceAll("[^\\w\\s-]", "") .trim() .replaceAll("\\s+", "-") .toLowerCase(Locale.ENGLISH); - - return slug; } @Override @@ -132,13 +140,18 @@ public List getAllBlogsForAdmin() { for (Blog b : blogs) { BlogListResponse dto = new BlogListResponse(); - dto.setId(b.getId()); // โญ THIS WAS MISSING + dto.setId(b.getId()); dto.setTitle(b.getTitle()); dto.setSlug(b.getSlug()); dto.setExcerpt(b.getExcerpt()); dto.setAuthorName(b.getAuthorName()); dto.setAuthorRole(b.getAuthorRole()); dto.setCreatedAt(b.getCreatedAt()); + + // โœ… CRITICAL FIX: Map the status to the boolean field + // This ensures the frontend sees correct status! + dto.setIsPublished(b.getStatus() == BlogStatus.PUBLISHED); + response.add(dto); } @@ -159,8 +172,10 @@ public BlogDetailResponse getBlogByIdForAdmin(Long id) { res.setAuthorRole(blog.getAuthorRole()); res.setCreatedAt(blog.getCreatedAt()); + // โœ… CRITICAL FIX: Map the status here as well + res.setIsPublished(blog.getStatus() == BlogStatus.PUBLISHED); + return res; } - -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/service/impl/BookingServiceImpl.java b/divinespark-backend/src/main/java/com/divinespark/service/impl/BookingServiceImpl.java index 2084333..fefe7eb 100644 --- a/divinespark-backend/src/main/java/com/divinespark/service/impl/BookingServiceImpl.java +++ b/divinespark-backend/src/main/java/com/divinespark/service/impl/BookingServiceImpl.java @@ -139,7 +139,7 @@ public UserSessionBookingResponse getMyBookingForSession( ) { Booking booking = bookingRepository - .findByUser_IdAndSession_Id(userId, sessionId) + .findByUserIdAndSessionId(userId, sessionId) .orElseThrow(() -> new RuntimeException("No booking found")); return new UserSessionBookingResponse( @@ -153,4 +153,4 @@ public UserSessionBookingResponse getMyBookingForSession( } -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/service/impl/InstallmentServiceImpl.java b/divinespark-backend/src/main/java/com/divinespark/service/impl/InstallmentServiceImpl.java index 0bd71bf..e3d3061 100644 --- a/divinespark-backend/src/main/java/com/divinespark/service/impl/InstallmentServiceImpl.java +++ b/divinespark-backend/src/main/java/com/divinespark/service/impl/InstallmentServiceImpl.java @@ -9,9 +9,11 @@ import com.divinespark.entity.Payment; import com.divinespark.entity.enums.BookingStatus; import com.divinespark.entity.enums.InstallmentStatus; +import com.divinespark.entity.enums.PaymentType; import com.divinespark.repository.BookingRepository; import com.divinespark.repository.InstallmentRepository; import com.divinespark.repository.PaymentRepository; +import com.divinespark.repository.UserRepository; import com.divinespark.service.InstallementService; import com.divinespark.service.RazorpayService; import org.springframework.stereotype.Service; @@ -19,6 +21,7 @@ import java.time.OffsetDateTime; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @@ -29,17 +32,20 @@ public class InstallmentServiceImpl implements InstallementService { private final BookingRepository bookingRepository; private final PaymentRepository paymentRepository; private final RazorpayService razorpayService; + private final UserRepository userRepository; public InstallmentServiceImpl( InstallmentRepository installmentRepository, BookingRepository bookingRepository, PaymentRepository paymentRepository, - RazorpayService razorpayService + RazorpayService razorpayService, + UserRepository userRepository ) { this.installmentRepository = installmentRepository; this.bookingRepository = bookingRepository; this.paymentRepository = paymentRepository; this.razorpayService = razorpayService; + this.userRepository = userRepository; } @Override @@ -129,42 +135,72 @@ public List getInstallmentsByBooking( } + + @Override @Transactional - public void markInstallmentPaid(String razorpayOrderId) { + public boolean verifyAndMarkInstallmentPaid( + String razorpayOrderId, + String razorpayPaymentId, + String razorpaySignature + ) { + // 1 Find installment by Razorpay order ID Installment installment = installmentRepository .findByRazorpayOrderId(razorpayOrderId) .orElseThrow(() -> new RuntimeException("Installment not found")); + Booking booking = installment.getBooking(); + + // 2 Verify Razorpay signature + System.out.println(">>> VERIFY SERVICE CALLED <<<"); + boolean verified = razorpayService.verifySignature( + razorpayOrderId, + razorpayPaymentId, + razorpaySignature + ); + + System.out.println("SIGNATURE VERIFIED = " + verified); + + + if (!verified) { + throw new RuntimeException("Payment verification failed"); + } + + // 3 Idempotency (avoid double update) if (installment.getStatus() == InstallmentStatus.PAID) { - return; + return true; } - // Mark installment paid + // 4 Mark FIRST installment as PAID installment.setStatus(InstallmentStatus.PAID); installment.setPaidAt(OffsetDateTime.now()); installmentRepository.save(installment); - Booking booking = installment.getBooking(); - - // Update booking amounts + // 5๏ธ Update booking amounts booking.setPaidAmount( booking.getPaidAmount() + installment.getAmount() ); + booking.setRemainingAmount( booking.getTotalAmount() - booking.getPaidAmount() ); - // Update booking status - boolean allPaid = installmentRepository - .findByBookingIdOrderByInstallmentNumber(booking.getId()) - .stream() - .allMatch(i -> i.getStatus() == InstallmentStatus.PAID); + // 6๏ธ Update booking status + booking.setBookingStatus(BookingStatus.PARTIALLY_PAID); + bookingRepository.save(booking); - booking.setBookingStatus( - allPaid ? BookingStatus.CONFIRMED : BookingStatus.PARTIALLY_PAID - ); + // 7๏ธ Update payment record + paymentRepository.findByGatewayOrderId(razorpayOrderId) + .ifPresent(p -> { + p.setStatus("SUCCESS"); + paymentRepository.save(p); + }); + + return true; } -} + + + +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/service/impl/PaymentServiceImpl.java b/divinespark-backend/src/main/java/com/divinespark/service/impl/PaymentServiceImpl.java index 066fef0..9ae72dd 100644 --- a/divinespark-backend/src/main/java/com/divinespark/service/impl/PaymentServiceImpl.java +++ b/divinespark-backend/src/main/java/com/divinespark/service/impl/PaymentServiceImpl.java @@ -42,7 +42,8 @@ public PaymentServiceImpl( public void handlePaymentFailure(String gatewayOrderId) { Payment payment = - paymentRepository.findByGatewayOrderId(gatewayOrderId); + paymentRepository.findByGatewayOrderId(gatewayOrderId) + .orElseThrow(() -> new RuntimeException("Payment not found")); if (payment == null) return; if ("FAILED".equals(payment.getStatus())) return; @@ -64,7 +65,8 @@ public void handlePaymentFailure(String gatewayOrderId) { public void handlePaymentSuccess(String gatewayOrderId) { Payment payment = - paymentRepository.findByGatewayOrderId(gatewayOrderId); + paymentRepository.findByGatewayOrderId(gatewayOrderId) + .orElseThrow(() -> new RuntimeException("Payment not found")); if (payment == null) return; if ("SUCCESS".equals(payment.getStatus())) return; @@ -95,7 +97,7 @@ public List getAllPaymentsForAdmin() { @Transactional public boolean handlePaymentCaptured(String razorpayOrderId, int amount) { - // 1๏ธโƒฃ INSTALLMENT PAYMENT (CHECK FIRST) + // 1๏ธ INSTALLMENT PAYMENT (CHECK FIRST) Installment installment = installmentRepository.findByRazorpayOrderId(razorpayOrderId) .orElse(null); @@ -145,9 +147,10 @@ public boolean handlePaymentCaptured(String razorpayOrderId, int amount) { return true; } - // 2๏ธโƒฃ FULL PAYMENT (CHECK AFTER) + // 2 FULL PAYMENT (CHECK AFTER) Payment payment = - paymentRepository.findByGatewayOrderId(razorpayOrderId); + paymentRepository.findByGatewayOrderId(razorpayOrderId) + .orElseThrow(() -> new RuntimeException("Payment not found")); if (payment != null) { @@ -173,4 +176,4 @@ public boolean handlePaymentCaptured(String razorpayOrderId, int amount) { } -} +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/service/impl/RazorpayServiceImpl.java b/divinespark-backend/src/main/java/com/divinespark/service/impl/RazorpayServiceImpl.java index 4b2c1a1..9ae72dd 100644 --- a/divinespark-backend/src/main/java/com/divinespark/service/impl/RazorpayServiceImpl.java +++ b/divinespark-backend/src/main/java/com/divinespark/service/impl/RazorpayServiceImpl.java @@ -1,47 +1,179 @@ package com.divinespark.service.impl; -import com.divinespark.config.RazorpayConfig; -import com.divinespark.dto.RazorpayOrderResponse; -import com.divinespark.service.RazorpayService; -import com.razorpay.Order; -import com.razorpay.RazorpayClient; -import org.json.JSONObject; +import com.divinespark.dto.AdminPaymentResponse; +import com.divinespark.entity.*; +import com.divinespark.entity.enums.BookingStatus; +import com.divinespark.entity.enums.InstallmentStatus; +import com.divinespark.entity.enums.PaymentType; +import com.divinespark.repository.BookingRepository; +import com.divinespark.repository.InstallmentRepository; +import com.divinespark.repository.PaymentRepository; +import com.divinespark.service.EmailService; +import com.divinespark.service.PaymentService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; @Service -public class RazorpayServiceImpl implements RazorpayService { +public class PaymentServiceImpl implements PaymentService { + + private final PaymentRepository paymentRepository; + private final BookingRepository bookingRepo; + private final EmailService emailService; + private final InstallmentRepository installmentRepository; + + public PaymentServiceImpl( + PaymentRepository paymentRepository, + BookingRepository bookingRepo, + EmailService emailService, + InstallmentRepository installmentRepository) { + + this.paymentRepository = paymentRepository; + this.bookingRepo = bookingRepo; + this.emailService = emailService; + this.installmentRepository = installmentRepository; + } + + // ================= FAILURE HANDLING ================= + + @Transactional + public void handlePaymentFailure(String gatewayOrderId) { + + Payment payment = + paymentRepository.findByGatewayOrderId(gatewayOrderId) + .orElseThrow(() -> new RuntimeException("Payment not found")); + + if (payment == null) return; + if ("FAILED".equals(payment.getStatus())) return; + + payment.setStatus("FAILED"); + paymentRepository.save(payment); + + Booking booking = bookingRepo + .findById(payment.getBookingId()) + .orElseThrow(() -> new RuntimeException("Booking not found")); + + booking.setBookingStatus(BookingStatus.CANCELLED); + bookingRepo.save(booking); + } + + // ================= SUCCESS HANDLING (FULL PAYMENT) ================= + + @Transactional + public void handlePaymentSuccess(String gatewayOrderId) { + + Payment payment = + paymentRepository.findByGatewayOrderId(gatewayOrderId) + .orElseThrow(() -> new RuntimeException("Payment not found")); + + if (payment == null) return; + if ("SUCCESS".equals(payment.getStatus())) return; + + payment.setStatus("SUCCESS"); + paymentRepository.save(payment); + + Booking booking = bookingRepo + .findById(payment.getBookingId()) + .orElseThrow(() -> new RuntimeException("Booking not found")); + + if (booking.getPaymentType() == PaymentType.FULL) { + booking.setBookingStatus(BookingStatus.CONFIRMED); + bookingRepo.save(booking); + } + } - private final RazorpayConfig razorpayConfig; + // ================= ADMIN ================= - public RazorpayServiceImpl(RazorpayConfig razorpayConfig) { - this.razorpayConfig = razorpayConfig; + @Override + public List getAllPaymentsForAdmin() { + return paymentRepository.fetchAdminPayments(); } + // ================= WEBHOOK (FULL + INSTALLMENT) ================= + @Override - public RazorpayOrderResponse createOrder(int amountInPaise, Long referenceId) { + @Transactional + public boolean handlePaymentCaptured(String razorpayOrderId, int amount) { + + // 1๏ธ INSTALLMENT PAYMENT (CHECK FIRST) + Installment installment = + installmentRepository.findByRazorpayOrderId(razorpayOrderId) + .orElse(null); + + if (installment != null) { + + if (installment.getStatus() == InstallmentStatus.PAID) { + return true; // idempotent + } + + installment.setStatus(InstallmentStatus.PAID); + installment.setPaidAt(OffsetDateTime.now()); + installmentRepository.save(installment); + + Booking booking = installment.getBooking(); + + double paidAmount = installmentRepository + .findByBooking_IdAndStatus( + booking.getId(), + InstallmentStatus.PAID + ) + .stream() + .mapToDouble(Installment::getAmount) + .sum(); - try { - RazorpayClient client = new RazorpayClient( - razorpayConfig.getKeyId(), - razorpayConfig.getKeySecret() + booking.setPaidAmount(paidAmount); + booking.setRemainingAmount( + Math.max(booking.getTotalAmount() - paidAmount, 0) ); - JSONObject orderRequest = new JSONObject(); - orderRequest.put("amount", amountInPaise); // already paise - orderRequest.put("currency", razorpayConfig.getCurrency()); - orderRequest.put("receipt", "ref_" + referenceId); - orderRequest.put("payment_capture", 1); + booking.setBookingStatus( + booking.getRemainingAmount() == 0 + ? BookingStatus.CONFIRMED + : BookingStatus.PARTIALLY_PAID + ); + + bookingRepo.save(booking); + + // Payment record (for admin / audit) + Payment p = new Payment(); + p.setBookingId(booking.getId()); + p.setAmount(installment.getAmount()); + p.setGatewayOrderId(razorpayOrderId); + p.setStatus("SUCCESS"); + paymentRepository.save(p); + + return true; + } - Order order = client.orders.create(orderRequest); + // 2 FULL PAYMENT (CHECK AFTER) + Payment payment = + paymentRepository.findByGatewayOrderId(razorpayOrderId) + .orElseThrow(() -> new RuntimeException("Payment not found")); - RazorpayOrderResponse response = new RazorpayOrderResponse(); - response.setOrderId(order.get("id")); + if (payment != null) { - return response; + if ("SUCCESS".equals(payment.getStatus())) { + return true; + } - } catch (Exception e) { - throw new RuntimeException("Failed to create Razorpay order", e); + payment.setStatus("SUCCESS"); + paymentRepository.save(payment); + + Booking booking = bookingRepo.findById(payment.getBookingId()) + .orElseThrow(); + + booking.setPaidAmount(booking.getTotalAmount()); + booking.setRemainingAmount(0); + booking.setBookingStatus(BookingStatus.CONFIRMED); + bookingRepo.save(booking); + + return true; } + + return false; } -} + +} \ No newline at end of file diff --git a/divinespark-backend/src/main/java/com/divinespark/service/impl/SessionServiceImpl.java b/divinespark-backend/src/main/java/com/divinespark/service/impl/SessionServiceImpl.java index d48a342..b5fd37d 100644 --- a/divinespark-backend/src/main/java/com/divinespark/service/impl/SessionServiceImpl.java +++ b/divinespark-backend/src/main/java/com/divinespark/service/impl/SessionServiceImpl.java @@ -192,7 +192,7 @@ public PaymentInitiateResponse initiatePaidSession(Long sessionId, Long userId) .orElseThrow(() -> new RuntimeException("User not found")); Booking booking = bookingRepository - .findByUser_IdAndSession_Id(userId, sessionId) + .findByUserIdAndSessionId(userId, sessionId) .orElseGet(() -> { Booking b = new Booking(); b.setSession(session); @@ -248,10 +248,29 @@ public InstallmentPaymentInitiateResponse initiateInstallmentPayment( throw new RuntimeException("Not a paid session"); } + if (session.getAvailableSeats().get() <= 0) { + throw new BusinessException("No seats available"); + } + User user = userRepo.findById(userId) .orElseThrow(() -> new RuntimeException("User not found")); - // Create booking + // block only real bookings + boolean alreadyBooked = + bookingRepository.existsByUserIdAndSessionIdAndBookingStatusIn( + userId, + sessionId, + List.of( + BookingStatus.CONFIRMED, + BookingStatus.PARTIALLY_PAID + ) + ); + + if (alreadyBooked) { + throw new BusinessException("You already booked this session"); + } + + // Create booking (PENDING) Booking booking = new Booking(); booking.setUser(user); booking.setSession(session); @@ -263,12 +282,11 @@ public InstallmentPaymentInitiateResponse initiateInstallmentPayment( booking.setWhatsappLink(session.getWhatsLink()); bookingRepository.save(booking); - session.getAvailableSeats().decrementAndGet(); - // Create installments (3 parts) + // Create installments double installmentAmount = session.getPrice() / 3.0; - List installments = new ArrayList<>(); + Installment firstInstallment = null; for (int i = 1; i <= 3; i++) { Installment inst = new Installment(); @@ -277,38 +295,45 @@ public InstallmentPaymentInitiateResponse initiateInstallmentPayment( inst.setAmount(installmentAmount); inst.setDueDate(OffsetDateTime.now().plusMonths(i - 1)); inst.setStatus(InstallmentStatus.PENDING); - installments.add(inst); - } + installmentRepository.save(inst); - installmentRepository.saveAll(installments); + if (i == 1) firstInstallment = inst; + } - // First installment ONLY - Installment firstInstallment = installments.get(0); + // Create Razorpay order for FIRST installment + int amountInPaise = (int) Math.round(installmentAmount * 100); - // Charge ONLY installment amount RazorpayOrderResponse order = - razorpayService.createOrder( - (int) Math.round(firstInstallment.getAmount() * 100), - booking.getId() - ); + razorpayService.createOrder(amountInPaise, booking.getId()); firstInstallment.setRazorpayOrderId(order.getOrderId()); installmentRepository.save(firstInstallment); - // Response + // Create payment (bookingId REQUIRED) + Payment payment = new Payment(); + payment.setBookingId(booking.getId()); + payment.setAmount(installmentAmount); + payment.setGatewayOrderId(order.getOrderId()); + payment.setStatus("CREATED"); + paymentRepository.save(payment); + + // Seat is reserved but NOT confirmed yet + InstallmentPaymentInitiateResponse res = new InstallmentPaymentInitiateResponse(); res.setBookingId(booking.getId()); res.setInstallmentId(firstInstallment.getId()); res.setRazorpayOrderId(order.getOrderId()); - res.setAmount((int) Math.round(firstInstallment.getAmount() * 100)); + res.setAmount(amountInPaise); return res; } + + @Override public List getUsersBySession(Long sessionId) { return bookingRepository.findUsersBySessionId(sessionId); @@ -404,9 +429,17 @@ public SessionDetailResponse getSessionDetails(Long sessionId) { public String getWhatsappLinkIfConfirmed(Long sessionId, Long userId) { Booking booking = bookingRepository - .findByUser_IdAndSession_Id(userId, sessionId) + .findFirstByUser_IdAndSession_IdAndBookingStatusInOrderByCreatedAtDesc( + userId, + sessionId, + List.of( + BookingStatus.CONFIRMED, + BookingStatus.PARTIALLY_PAID + ) + ) .orElseThrow(() -> new ExpressionException("Access denied")); + if ( booking.getBookingStatus() != BookingStatus.CONFIRMED && booking.getBookingStatus() != BookingStatus.PARTIALLY_PAID @@ -424,4 +457,4 @@ public String getWhatsappLinkIfConfirmed(Long sessionId, Long userId) { } -} +} \ No newline at end of file